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
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:
parent
f7e0277552
commit
af2e09c1de
8 changed files with 603 additions and 10 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
175
src/utils/slideShapes.js
Normal 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, 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 };
|
||||
88
test/slide-shapes.test.js
Normal file
88
test/slide-shapes.test.js
Normal 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');
|
||||
});
|
||||
Loading…
Reference in a new issue