// ============================================================ // 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']; // Not drawn. A way for the model to say what it wanted and could not have, so // the vocabulary grows from what people actually ask for rather than from // guesses about what might be useful. var UNSUPPORTED = 'unsupported'; 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, gaps) { if (!Array.isArray(list)) return []; var out = []; var note = function (wanted, detail) { if (!Array.isArray(gaps)) return; var name = String(wanted || 'unknown').slice(0, 60); if (gaps.length < 12) gaps.push({ wanted: name, detail: String(detail || '').slice(0, 240) }); }; list.slice(0, MAX_SHAPES).forEach(function (raw) { if (!raw || typeof raw !== 'object') return; // Said outright: "I wanted a SmartArt cycle here." Recorded and not drawn. if (raw.kind === UNSUPPORTED) { note(raw.need || raw.kind, raw.need); return; } // Reached for something that does not exist. Just as much of a signal as // saying so, and more common — a model asks by trying. if (raw.kind && KINDS.indexOf(raw.kind) === -1) { note(raw.kind, 'used as a shape kind'); } 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') { if (raw.chart && CHARTS.indexOf(raw.chart) === -1) note(raw.chart, 'asked for as a chart type'); 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.', '', ' If you need something this list cannot express, include', ' {"kind":"unsupported","need":"a SmartArt cycle of four stages"} and lay the', ' slide out as well as you can without it. Nothing is drawn for that entry —', ' it is how the vocabulary learns what to add next, so say plainly what you', ' wanted rather than working around it silently.' ].join('\n'); } module.exports = { normalise, instructions, KINDS, CHARTS, MAX_SHAPES, UNSUPPORTED };