Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 52s
Forgejo Docker Build / Root app tests (push) Successful in 52s
Forgejo Android APK / Build signed APK (push) Successful in 2m15s
Forgejo Docker Build / Build Docker image (push) Successful in 10s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
"Include a diagram" produced decks with no picture. Three separate faults, each hiding the next, found by generating the same deck after fixing each one. First, deck generation ran on the default 4000-token budget. A deck's JSON is several times the size of the prose it holds, so a long deck came back truncated, failed to parse, and fell back to markdown — which has no way to request a figure, so the model described one instead and the slide rendered a literal "![Placeholder: Flow diagram ...]" as its first bullet. Deck generation now gets room, and the fallback says how the reply failed: empty, cut short at N characters, or not a deck. Second, the figure request sat inside the layout vocabulary, one line among forty, and the model passed over it. It goes last now, after the author's own instructions — the same placement lesson the image tool taught earlier. Third, and the one that actually mattered: image_prompt is only read on the figure and image types, so an image_prompt on a bullets slide was dropped in silence. The instruction said "add image_prompt to N slides" without saying which types carry one. It now names them, and a misplaced request is honoured rather than discarded — a slide with words becomes a figure, one without becomes a full-slide image. Image markup is also stripped wherever text enters a slide, on both paths: a described figure is not a figure, and a bullet of raw markdown is worse than no bullet. Verified end to end after: the same request produced a deck with one figure, the job completed, and the exported pptx carries one embedded image across 21 slides. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
244 lines
13 KiB
JavaScript
244 lines
13 KiB
JavaScript
// ============================================================
|
|
// SLIDE SPEC
|
|
// ============================================================
|
|
// The half of deck rendering that decides what each slide is. Pandoc had no
|
|
// such step — every slide became bullets on a reference layout — so this is
|
|
// where most of the difference in a generated deck now comes from.
|
|
|
|
const test = require('node:test');
|
|
const assert = require('node:assert');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const slideSpec = require('../src/utils/slideSpec');
|
|
const read = p => fs.readFileSync(path.join(__dirname, '..', p), 'utf8');
|
|
|
|
const DECK = [
|
|
'% Croup in Children', '% Teaching Resource', '% 2026', '',
|
|
'# What Croup Is', '', '- A viral illness', '- Peaks at 12-18 months', '',
|
|
'# Severity', '',
|
|
'| Feature | Mild | Severe |', '|---|---|---|',
|
|
'| Stridor | Absent | Present |', '| Retractions | None | Marked |', '',
|
|
'# Long list', '',
|
|
'- one', '- two', '- three', '- four', '- five', '- six', '- seven', '- eight', '',
|
|
'# References', '', '- Nelson, p. 2606'
|
|
].join('\n');
|
|
|
|
test('a title block becomes a title slide, not a bullet', () => {
|
|
const spec = slideSpec.build(DECK, {});
|
|
assert.equal(spec.slides[0].type, 'title');
|
|
assert.equal(spec.slides[0].heading, 'Croup in Children');
|
|
assert.equal(spec.slides[0].subtitle, 'Teaching Resource');
|
|
assert.equal(spec.slides[0].date, '2026');
|
|
});
|
|
|
|
test('each slide gets the layout its content needs', () => {
|
|
const byHeading = {};
|
|
slideSpec.build(DECK, {}).slides.forEach(s => { if (s.heading) byHeading[s.heading] = s; });
|
|
|
|
assert.equal(byHeading['What Croup Is'].type, 'bullets');
|
|
// A pipe table is a table, not eight lines of text with pipes in them.
|
|
assert.equal(byHeading['Severity'].type, 'table');
|
|
assert.deepEqual(byHeading['Severity'].header, ['Feature', 'Mild', 'Severe']);
|
|
assert.equal(byHeading['Severity'].rows.length, 2, 'the alignment rule is not a row');
|
|
// A long list is unreadable at any legible size in one column.
|
|
assert.equal(byHeading['Long list'].type, 'two');
|
|
assert.equal(byHeading['Long list'].left.length + byHeading['Long list'].right.length, 8);
|
|
});
|
|
|
|
test('figures are spread through the deck, and References stays last', () => {
|
|
const spec = slideSpec.build(DECK, { images: ['/tmp/a.png', '/tmp/b.png'] });
|
|
const types = spec.slides.map(s => s.type);
|
|
assert.equal(types.filter(t => t === 'image').length, 2);
|
|
// Appending them would end the deck with unexplained pictures.
|
|
assert.notEqual(types[types.length - 1], 'image');
|
|
assert.equal(spec.slides[spec.slides.length - 1].heading, 'References');
|
|
// And never before the first content slide.
|
|
assert.ok(types.indexOf('image') > 1);
|
|
});
|
|
|
|
test('a slide with no list still becomes a slide', () => {
|
|
const spec = slideSpec.build('# Just a heading\n', {});
|
|
assert.equal(spec.slides[0].type, 'section');
|
|
assert.equal(spec.slides[0].heading, 'Just a heading');
|
|
});
|
|
|
|
test('the renderer sizes text to fit rather than trusting autofit', () => {
|
|
const py = read('scripts/render_pptx.py');
|
|
// LibreOffice ignores <a:normAutofit/> when converting to PDF, which is how
|
|
// slides were being cut off mid-sentence.
|
|
assert.match(py, /def _fit_size\(/);
|
|
assert.match(py, /BULLET_SIZES = /);
|
|
// 16:9. Pandoc's reference doc is 4:3.
|
|
assert.match(py, /SLIDE_W = Emu\(12192000\)/);
|
|
// An image keeps its own aspect ratio; the old pptxgenjs path stretched every
|
|
// one of them to the target box.
|
|
assert.match(py, /ratio = img\.width \/ float\(img\.height\)/);
|
|
assert.match(py, /width = int\(height \* ratio\)/);
|
|
// Wrapped lines hang under the text.
|
|
assert.match(py, /pPr\.set\("indent", str\(-indent\)\)/);
|
|
});
|
|
|
|
test('the deck renderer replaces pandoc, and pandoc still catches it if it falls', () => {
|
|
const exporter = read('src/utils/documentExport.js');
|
|
assert.match(exporter, /async function buildDeck\(markdown, workdir, images, options\)/);
|
|
// One spawn helper, now that the document renderer uses it too.
|
|
assert.match(exporter, /runRenderer\(DECK_RENDERER, out, spec, workdir\)/);
|
|
assert.match(exporter, /spawn\('python3', \[script, out\]/);
|
|
// A plainer deck beats a failed download.
|
|
assert.match(exporter, /deck renderer failed, falling back to pandoc/);
|
|
assert.match(exporter, /runPandoc\(\['doc\.md', '--reference-doc=' \+ REFERENCE_DECK/);
|
|
// Word is still pandoc's, where its output is good.
|
|
assert.match(exporter, /runPandoc\(\['doc\.md', '-o', 'doc\.docx'\]/);
|
|
// And the runtime actually has it.
|
|
assert.match(read('Dockerfile'), /python-pptx==1\.0\.2/);
|
|
});
|
|
|
|
test('a resource remembers its figures, so an export can include them', () => {
|
|
const route = read('src/routes/myResources.js');
|
|
// They were queued and shown on screen, but nothing tied them to the
|
|
// resource, so an exported deck could never contain them.
|
|
assert.match(read('migrations/1780500000000_resource-images.js'), /image_ids JSONB/);
|
|
assert.match(route, /var savedFigureIds = \(ai\.imageJobs \|\| \[\]\)\.map/);
|
|
// "Add two more diagrams" means more, not instead.
|
|
assert.match(route, /image_ids = image_ids \|\| \?::jsonb/);
|
|
assert.match(route, /async function collectFigures\(ids, user, dir\)/);
|
|
// A figure that cannot be fetched is left out rather than failing a download.
|
|
assert.match(route, /figure unavailable for export/);
|
|
assert.match(route, /documentExport\.render\(row\.markdown, row\.kind, format,\s*\n?\s*\{ images: figures, deck: row\.deck/);
|
|
});
|
|
|
|
// ── Deck mode ───────────────────────────────────────────────
|
|
// The model designs the deck instead of writing markdown for a parser to guess
|
|
// at. Measured on a live generation: the model chose section, bullets, table,
|
|
// compare, figure, callout and image layouts for one topic — none of which
|
|
// markdown can ask for.
|
|
|
|
const deckSchema = require('../src/utils/deckSchema');
|
|
const deckBuild = require('../src/utils/deckBuild');
|
|
|
|
test('a model reply is read as a deck even when it is wrapped', () => {
|
|
// Fences and a covering sentence are common enough that refusing them would
|
|
// be pedantry.
|
|
assert.ok(deckBuild.extractJson('```json\n{"title":"A","slides":[]}\n```'));
|
|
assert.ok(deckBuild.extractJson('Here you go: {"title":"A","slides":[]} hope that helps'));
|
|
// A brace inside a string is not the end of the object.
|
|
assert.equal(deckBuild.extractJson('{"title":"a } b","slides":[]}').title, 'a } b');
|
|
assert.equal(deckBuild.extractJson('no json at all'), null);
|
|
// Nothing usable means fall back to markdown, not save an apology.
|
|
assert.equal(deckBuild.parse('{"slides":[]}'), null);
|
|
});
|
|
|
|
test('a malformed slide costs that slide, not the deck', () => {
|
|
const deck = deckSchema.normalise({
|
|
title: 'T',
|
|
slides: [
|
|
{ type: 'bullets', heading: 'Fine', bullets: ['one', { text: 'two', level: 1 }] },
|
|
{ type: 'nonsense', heading: 'Unknown type', bullets: ['kept'] },
|
|
{ type: 'compare', heading: 'Only one column', columns: [{ label: 'A', bullets: ['x'] }] },
|
|
{ type: 'table', heading: 'No rows', header: ['a'], rows: [] },
|
|
{ type: 'callout', heading: 'Empty', text: '' },
|
|
],
|
|
});
|
|
assert.equal(deck.slides[0].bullets.length, 2);
|
|
assert.equal(deck.slides[0].bullets[1].level, 1);
|
|
assert.equal(deck.slides[1].type, 'bullets', 'an unknown type degrades rather than throwing');
|
|
assert.equal(deck.slides[2].type, 'bullets', 'a comparison needs two columns to be one');
|
|
assert.equal(deck.slides[3].type, 'bullets', 'an empty table is not a table');
|
|
assert.equal(deck.slides.length, 4, 'a callout with nothing to say is dropped');
|
|
});
|
|
|
|
test('the deck still serialises to markdown, because Word and text edits need it', () => {
|
|
const md = deckSchema.toMarkdown(deckSchema.normalise({
|
|
title: 'Croup', subtitle: 'Teaching', date: '2026',
|
|
slides: [
|
|
{ type: 'compare', heading: 'Versus', columns: [
|
|
{ label: 'CROUP', bullets: ['barking cough'] },
|
|
{ label: 'EPIGLOTTITIS', bullets: ['drooling'] }] },
|
|
{ type: 'table', heading: 'Features', header: ['Feature', 'Croup'], rows: [['Onset', 'Days']] },
|
|
{ type: 'callout', heading: 'Red flag', text: 'Do not examine the throat.' },
|
|
],
|
|
}));
|
|
assert.match(md, /^% Croup/m);
|
|
assert.match(md, /^## CROUP$/m);
|
|
assert.match(md, /^\| Feature \| Croup \|$/m);
|
|
assert.match(md, /\*\*Do not examine the throat\.\*\*/);
|
|
});
|
|
|
|
test('a figure that never arrives leaves a slide of text, not an empty frame', () => {
|
|
const exporter = read('src/utils/documentExport.js');
|
|
assert.match(exporter, /function attachFigures\(deck, files, figureIds\)/);
|
|
assert.match(exporter, /if \(copy\.image_job && byJob\[copy\.image_job\]\) copy\.image = byJob\[copy\.image_job\];/);
|
|
assert.match(exporter, /copy\.type = copy\.type === 'image' \? 'section' : 'bullets';/);
|
|
// And a deck the model designed is rendered as designed; only older resources
|
|
// fall back to inferring a layout from their markdown.
|
|
assert.match(exporter, /options\.deck\s*\n?\s*\? attachFigures/);
|
|
|
|
const build = read('src/utils/deckBuild.js');
|
|
assert.match(build, /if \(!wanted\.length \|\| !opts\.imageModel\)/, 'no image model configured is not an error');
|
|
assert.match(build, /a figure could not be queued/);
|
|
});
|
|
|
|
test('the renderer can draw what the schema offers', () => {
|
|
const py = read('scripts/render_pptx.py');
|
|
for (const type of deckSchema.VALID) {
|
|
assert.match(py, new RegExp('"' + type + '": slide_'), type + ' has a builder');
|
|
}
|
|
});
|
|
|
|
test('a described figure never reaches a slide as text', () => {
|
|
// A model with no way to request a figure describes one instead, and
|
|
// "![Placeholder: Flow diagram — ...]" then renders as a bullet of literal
|
|
// markdown. Seen on a real deck: the placeholder was the first bullet of the
|
|
// slide, above the steps it was meant to illustrate.
|
|
const deck = deckSchema.normalise({ slides: [{ type: 'bullets', heading: 'Pathway', bullets: [
|
|
{ text: '![Placeholder: Flow diagram — "Neonate with rash" → branch]' },
|
|
{ text: 'Step 1: Is the infant well?' },
|
|
{ text: 'See  for detail' },
|
|
]}]});
|
|
assert.deepEqual(deck.slides[0].bullets.map(b => b.text),
|
|
['Step 1: Is the infant well?', 'See for detail']);
|
|
|
|
// The markdown path is where it actually appeared, so it is guarded too.
|
|
const spec = slideSpec.build('# Pathway\n\n- ![Placeholder: a diagram]\n- Step 1: real content\n', {});
|
|
assert.deepEqual(spec.slides[0].bullets.map(b => b.text), ['Step 1: real content']);
|
|
|
|
// And the model is told to stop doing it.
|
|
assert.match(deckSchema.instructions(8, 0), /Never write an image placeholder into any text/);
|
|
});
|
|
|
|
test('a deck is given room to be a deck', () => {
|
|
const route = read('src/routes/myResources.js');
|
|
// The default budget is 4000 tokens. A deck's JSON is several times the size
|
|
// of the prose it holds, so a sixteen-slide deck ran past it, came back
|
|
// truncated, failed to parse, and fell back to markdown — which cannot ask
|
|
// for a figure at all. That is why decks were arriving with no images.
|
|
assert.match(route, /if \(deckMode\) options\.maxTokens = 16000;/);
|
|
// And the fallback says how it failed, because "not usable" covers a
|
|
// truncated reply, prose instead of JSON, and an empty deck.
|
|
assert.match(route, /cut short at ' \+ replyLength \+ ' chars'/);
|
|
assert.match(route, /empty reply/);
|
|
});
|
|
|
|
test('a figure asked for the wrong way is honoured, not dropped', () => {
|
|
// image_prompt is only carried by the figure and image types, so a model that
|
|
// put one on a bullets slide had it silently discarded — it asked for a
|
|
// picture, got none, and nothing said why. Measured on a real generation: a
|
|
// twenty-slide deck, an explicit "include a diagram", zero image_prompt.
|
|
const deck = deckSchema.normalise({ slides: [
|
|
{ type: 'bullets', heading: 'Pathway', bullets: ['a', 'b'], image_prompt: 'a flow diagram' },
|
|
{ type: 'section', heading: 'Overview', image_prompt: 'a full-bleed picture' },
|
|
]});
|
|
// Words beside a picture is a figure; no words is a full-slide image.
|
|
assert.equal(deck.slides[0].type, 'figure');
|
|
assert.equal(deck.slides[0].image_prompt, 'a flow diagram');
|
|
assert.equal(deck.slides[1].type, 'image');
|
|
|
|
// And the instruction now names the only two types that carry one, last in
|
|
// the prompt, after the author's own instructions.
|
|
const route = read('src/routes/myResources.js');
|
|
assert.match(route, /Those two types are the only ones that carry a picture/);
|
|
const deckBranch = route.slice(route.indexOf("if (kind === 'presentation' && opts.deckMode)"));
|
|
const built = deckBranch.indexOf('var figures =');
|
|
const used = deckBranch.indexOf("+\n figures;");
|
|
assert.ok(built > -1 && used > built, 'the figure request is appended last');
|
|
});
|