feat: a slide can be drawn from primitives when the named layouts have no word for it
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 1m7s
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Android APK / Build signed APK (push) Successful in 2m16s
Forgejo Docker Build / Build Docker image (push) Successful in 16s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s

The nine layouts are a fixed vocabulary and a good default, but "lay the three
severity levels out left to right with arrows between them" had no expression in
them at all. A "custom" slide now carries a list of shapes: positioned text,
eight autoshape families, lines, images, tables, and native PowerPoint charts —
column, bar, line, pie, doughnut.

Coordinates are percentages of the slide rather than EMU, because a model
reasons about "the left half" and not about 12192000. 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 holding clinical data and secrets.

Validation lives beside the text that teaches the vocabulary, in one file, so
what the model is told about is exactly what is accepted. Kinds are an
allowlist, colours must be six hex digits, coordinates are clamped inside the
slide — a shape at x=95 w=30 is cut to the edge rather than drawn half off it —
counts are capped, a pie is held to one series, and anything that cannot be
understood is dropped. A custom slide that loses every shape becomes a plain one
rather than a heading over an empty frame, and one bad shape is caught in the
renderer so it cannot cost the slide it sits on.

A figure on a custom slide is requested through an image shape, drawn by the
same path as any other, and attached by job id. Word renders a custom slide as
its words in reading order with its tables and figures — lossy, and better than
dropping the slide.

Verified live end to end: 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 the rendered
slide was looked at. A column chart beside its commentary renders with real axes
and gridlines.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
This commit is contained in:
Daniel 2026-09-11 22:12:26 +02:00
parent f7e0277552
commit af2e09c1de
8 changed files with 603 additions and 10 deletions

View file

@ -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, 0100, 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:

View file

@ -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,
}

View file

@ -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 };
}

View file

@ -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;
}

View file

@ -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) {

View file

@ -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

175
src/utils/slideShapes.js Normal file
View file

@ -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, 0100, 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 };

88
test/slide-shapes.test.js Normal file
View file

@ -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', '<script>']) {
assert.equal(shapes.normalise([{ kind: 'rect', fill: bad, text: 'x' }])[0].fill, null, bad);
}
});
test('what cannot be drawn is dropped, not guessed at', () => {
// An unknown kind becomes text, and text with nothing in it is not a shape.
assert.equal(shapes.normalise([{ kind: 'bogus', x: 1, y: 1 }]).length, 0);
assert.equal(shapes.normalise([{ kind: 'table', header: ['A'], rows: [] }]).length, 0, 'an empty table');
assert.equal(shapes.normalise([{ kind: 'chart', chart: 'column', categories: [], series: [] }]).length, 0);
assert.equal(shapes.normalise([{ kind: 'image' }]).length, 0, 'an image with nothing to draw');
assert.equal(shapes.normalise('not an array').length, 0);
// A line carries no words and is still a shape.
assert.equal(shapes.normalise([{ kind: 'line', x: 6, y: 50, w: 88, h: 0, line: 'E5E7EB' }]).length, 1);
});
test('counts are capped, because a model can always ask for more', () => {
assert.equal(shapes.normalise(new Array(200).fill({ kind: 'rect', fill: 'FFFFFF' })).length, shapes.MAX_SHAPES);
const wide = one({ kind: 'table', header: new Array(20).fill('h'), rows: [new Array(20).fill('c')] });
assert.equal(wide.header.length, 8);
assert.equal(wide.rows[0].length, 8);
// A pie has one series by definition; more would be silently ignored anyway.
const pie = one({ kind: 'chart', chart: 'pie', categories: ['a', 'b'],
series: [{ name: 'S', values: [1, 2] }, { name: 'T', values: [3, 4] }] });
assert.equal(pie.series.length, 1);
// An unknown chart type falls back rather than reaching the renderer.
assert.equal(one({ kind: 'chart', chart: 'radar', categories: ['a'], series: [{ values: [1] }] }).chart, 'column');
});
test('the documented vocabulary is the accepted vocabulary', () => {
// The prompt and the validator live in the same file so they cannot drift:
// a kind the model is told about but the validator drops is a silent failure.
const doc = shapes.instructions();
for (const kind of shapes.KINDS) {
assert.ok(doc.includes('"' + kind + '"') || doc.includes(kind), kind + ' is described to the model');
}
// And the renderer can draw every one of them.
const py = read('scripts/render_pptx.py');
for (const kind of ['rect', 'roundRect', 'ellipse', 'arrow', 'arrowDown', 'chevron', 'diamond', 'hexagon']) {
assert.match(py, new RegExp('"' + kind + '": MSO_SHAPE'), kind);
}
for (const kind of shapes.CHARTS) {
assert.match(py, new RegExp('"' + kind + '": XL_CHART_TYPE'), kind);
}
assert.match(py, /"custom": slide_custom/);
// One bad shape must not cost the slide it is on.
assert.match(py, /except Exception as exc: # one bad shape must not cost the slide/);
});
test('a custom slide that loses every shape becomes a plain one', () => {
const deckSchema = require('../src/utils/deckSchema');
const deck = deckSchema.normalise({ slides: [
{ type: 'custom', heading: 'Nothing usable', shapes: [{ kind: 'bogus' }], bullets: ['fallback'] },
]});
// A heading over an empty frame is worse than the bullets it also sent.
assert.equal(deck.slides[0].type, 'bullets');
assert.equal(deck.slides[0].bullets[0].text, 'fallback');
});