// ============================================================ // 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 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 figureIds = \(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'); } });