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
88 lines
4.7 KiB
JavaScript
88 lines
4.7 KiB
JavaScript
// ============================================================
|
|
// 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');
|
|
});
|