Some checks failed
Forgejo Docker Build / Build Docker image (push) Blocked by required conditions
Forgejo Docker Build / Deploy to the host (push) Blocked by required conditions
Forgejo Android APK / Root app tests (push) Successful in 58s
Forgejo Docker Build / Root app tests (push) Successful in 49s
Forgejo Android APK / Build signed APK (push) Has been cancelled
Pandoc reads markdown, so every Word export had to flatten the resource to markdown first — and a deck flattened to markdown stops being one. A comparison became two headings and two lists, a callout became bold text, and a figure became nothing at all, because markdown has nowhere to put it. src/utils/docSpec.js reduces either source to the same blocks: a stored deck where there is one, the markdown where there is not. scripts/render_docx.py draws them. A comparison comes out as a labelled two-column table, a callout as a shaded box, a table as a real table, a figure embedded at its own aspect ratio with its caption, and speaker notes as muted indented text. The deck wins over the markdown beside it, because that markdown is a serialisation of the deck and reading it instead would be reading a lossy copy of what is right there. Word now carries the figures too. The export route skipped fetching them for docx, which was correct when pandoc could not place them and wrong the moment this could. Pandoc stays installed and stays the fallback: a plainer document beats a failed download. Both renderers now share one spawn helper. Verified end to end: a deck with two figures exported as a six-page Word document with both images embedded (537KB, two files in word/media), rendered to PDF and looked at — the comparison is a labelled table, the figure sits at its true aspect ratio, and the notes read as notes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
186 lines
9.3 KiB
JavaScript
186 lines
9.3 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 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');
|
|
}
|
|
});
|