fix: slides shrink to fit, and an article is never offered as slides
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 49s
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Android APK / Build signed APK (push) Successful in 2m0s
Forgejo Docker Build / Build Docker image (push) Successful in 15s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s

A generated deck was losing content. pandoc writes a bare <a:bodyPr/> on every
shape, which leaves the body with no autofit even though the slide master has
one, so a slide with too much on it is cut off mid-sentence and the rest is not
rendered at all. Reproduced and counted: eight bullets went in, three came out,
the third ending mid-word.

Every generated deck now carries <a:normAutofit/> on its body placeholders. No
fontScale, deliberately — the renderer works out the reduction, so a slide that
already fits is untouched, where a fixed scale would shrink all of them. The
same eight bullets now fit with nothing in the bottom 6% of the slide.

This is a floor, not a licence to overcrowd. The prompt still asks for one idea
per slide; this stops a long one becoming unreadable.

Also: an article is no longer offered as PowerPoint. A deck of paragraphs is not
a presentation. Word and PDF suit either kind, and the route refuses the
combination rather than relying on the button being absent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
This commit is contained in:
Daniel 2026-09-11 15:34:16 +02:00
parent efa84ac0e6
commit 7eca509b02
7 changed files with 247 additions and 12 deletions

7
package-lock.json generated
View file

@ -27,6 +27,7 @@
"express-rate-limit": "^7.4.0",
"helmet": "^8.0.0",
"jsonwebtoken": "^9.0.2",
"jszip": "^3.10.2",
"katex": "^0.18.7",
"mammoth": "^1.8.0",
"markdown-it": "^14.1.1",
@ -4941,9 +4942,9 @@
"license": "MIT"
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"version": "3.10.2",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.2.tgz",
"integrity": "sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",

View file

@ -35,6 +35,7 @@
"express-rate-limit": "^7.4.0",
"helmet": "^8.0.0",
"jsonwebtoken": "^9.0.2",
"jszip": "^3.10.2",
"katex": "^0.18.7",
"mammoth": "^1.8.0",
"markdown-it": "^14.1.1",

View file

@ -48,6 +48,31 @@
</div>
</div>
<div class="admin-row" id="mr-model-row" hidden>
<label for="mr-model" class="admin-row-label">Model</label>
<div style="flex:1;display:flex;flex-direction:column;gap:4px;min-width:0;">
<select id="mr-model" class="admin-control" style="max-width:320px;"></select>
<p style="margin:0;font-size:12px;color:var(--g500);">
Only shown when an administrator has approved more than one. Otherwise the
configured model is used.
</p>
</div>
</div>
<div class="admin-row" id="mr-images-row" hidden>
<strong class="admin-row-label">Illustrations</strong>
<div style="flex:1;display:flex;flex-direction:column;gap:4px;min-width:0;">
<label style="display:flex;align-items:center;gap:8px;font-size:13px;">
<input type="checkbox" id="mr-with-images">
Let the model add an image where one genuinely helps
</label>
<p style="margin:0;font-size:12px;color:var(--g500);">
Off by default: a model handed a drawing tool will find a reason to use it, and
most teaching material does not want one. Images appear in your image history.
</p>
</div>
</div>
<div class="admin-row" style="align-items:flex-start;">
<label for="mr-refinement" class="admin-row-label">Instructions <span style="color:var(--g400);font-weight:400;">(optional)</span></label>
<textarea id="mr-refinement" class="admin-control" style="min-height:70px;resize:vertical;font-family:inherit;" placeholder="e.g. for FY1s, case-based, emphasise red flags"></textarea>

View file

@ -32,6 +32,36 @@
// row would leak listeners and miss anything added later.
var list = document.getElementById('mr-list');
if (list) list.addEventListener('click', onRowClick);
loadOptions();
}
// What an administrator has approved. The model row stays hidden unless there
// is a genuine choice to make — one approved model is not a decision anyone
// should be asked to take.
function loadOptions() {
fetch('/api/my-resources/options', { headers: getAuthHeaders() })
.then(function (r) { return r.json(); })
.then(function (data) {
if (!data || !data.success) return;
var select = document.getElementById('mr-model');
var modelRow = document.getElementById('mr-model-row');
var models = data.models || [];
if (select) {
select.textContent = '';
models.forEach(function (id) {
var option = document.createElement('option');
option.value = id;
option.textContent = id;
if (id === data.defaultModel) option.selected = true;
select.appendChild(option);
});
}
if (modelRow) modelRow.hidden = models.length < 2;
var imagesRow = document.getElementById('mr-images-row');
if (imagesRow) imagesRow.hidden = !data.imagesAvailable;
})
.catch(function () { /* the defaults still work without this */ });
}
function syncFormatFields() {
@ -67,7 +97,9 @@
slideCount: (document.getElementById('mr-slide-count') || {}).value,
wordCount: (document.getElementById('mr-word-count') || {}).value,
refinement: (document.getElementById('mr-refinement') || {}).value || '',
useCorpus: corpusBox && corpusBox.checked === false ? 'false' : 'true'
useCorpus: corpusBox && corpusBox.checked === false ? 'false' : 'true',
model: (document.getElementById('mr-model') || {}).value || '',
withImages: (document.getElementById('mr-with-images') || {}).checked ? 'true' : 'false'
})
})
.then(function (r) { return r.json(); })
@ -80,6 +112,9 @@
? 'Saved. Written from ' + g.count + ' library excerpt' + (g.count === 1 ? '' : 's') + '.'
: 'Saved. Not grounded' + (g.reason ? ' — ' + g.reason : '') + '; written from the model alone.',
g.used ? 'good' : null);
if ((data.imageJobs || []).length && typeof showToast === 'function') {
showToast('An illustration is being generated; it will appear in your image history.', 'info');
}
loadLibrary();
})
.catch(function (err) { status(err.message, 'bad'); })
@ -139,7 +174,11 @@
body.appendChild(meta);
wrap.appendChild(body);
['pptx', 'docx', 'pdf'].forEach(function (format) {
// An article has no slides, so offering PowerPoint would produce a deck of
// paragraphs. A presentation as Word is fine — prose absorbs slide content
// without overflowing anything.
var formats = row.kind === 'article' ? ['docx', 'pdf'] : ['pptx', 'docx', 'pdf'];
formats.forEach(function (format) {
var btn = document.createElement('button');
btn.className = 'btn-sm btn-ghost';
btn.type = 'button';

View file

@ -20,6 +20,7 @@ var db = require('../db/database');
var { authMiddleware } = require('../middleware/auth');
var { callAI } = require('../utils/ai');
var learningRetrieval = require('../utils/learningRetrieval');
var imageTool = require('../utils/imageTool');
var documentExport = require('../utils/documentExport');
// Scoped to this router's own prefix. Mounted on /api, a bare
@ -38,6 +39,26 @@ function clampInt(value, min, max, fallback) {
// Presentation and article are the two shapes markdown renders well into, and
// the only two the exporter knows. Anything else is rejected rather than
// guessed at.
// The models a user may choose from are the ones an admin already curates for
// the assistant. A second allow-list would be another thing to keep in step,
// and would let this feature reach a model the institution never approved.
async function allowedModels() {
var configured = String(await db.getSetting('clinical_assistant.chat_model', '') || '').trim();
var allowed = String(await db.getSetting('clinical_assistant.allowed_models', '') || '')
.split(',').map(function (m) { return m.trim(); }).filter(Boolean);
if (configured && allowed.indexOf(configured) === -1) allowed.unshift(configured);
return { configured: configured, allowed: allowed };
}
// Anything not on the list falls back to the configured default rather than
// being refused: a stale option in a browser tab should not cost someone their
// generation.
async function resolveModel(requested) {
var models = await allowedModels();
var wanted = String(requested || '').trim();
return wanted && models.allowed.indexOf(wanted) !== -1 ? wanted : (models.configured || undefined);
}
function normalizeKind(kind) {
return String(kind) === 'article' ? 'article' : 'presentation';
}
@ -79,6 +100,23 @@ function firstHeading(markdown, fallback) {
return (m ? m[1] : fallback || 'Untitled').trim().slice(0, MAX_TITLE);
}
// What this user may choose. Driven entirely by admin settings, so the screen
// shows a single fixed model until an admin allows more — exactly like chat.
router.get('/my-resources/options', async function (req, res) {
try {
var models = await allowedModels();
res.json({
success: true,
models: models.allowed,
defaultModel: models.configured,
imagesAvailable: Boolean(await db.getSetting('clinical_assistant.image_model', ''))
});
} catch (err) {
console.error('[my-resources] options:', err.message);
res.status(500).json({ error: 'Could not load options' });
}
});
// ── Generate ────────────────────────────────────────────────
router.post('/my-resources/generate', async function (req, res) {
try {
@ -105,9 +143,29 @@ router.post('/my-resources/generate', async function (req, res) {
wordCount: clampInt(req.body.wordCount, 200, 3000, 800)
});
var ai = await callAI([{ role: 'user', content: prompt }], {
model: req.body.model || undefined, temperature: 0.3
});
// Illustration is opt-in per generation. The tool is only offered when the
// author ticked the box, because a model handed a drawing tool will find a
// reason to use it, and most teaching material does not want one.
var wantsImages = String(req.body.withImages) === 'true' || req.body.withImages === true;
var imageModel = wantsImages ? String(await db.getSetting('clinical_assistant.image_model', '') || '') : '';
var model = await resolveModel(req.body.model);
var messages = [{ role: 'user', content: prompt }];
var options = { model: model, temperature: 0.3 };
var ai = await callAI(messages, wantsImages && imageModel
? Object.assign({}, options, { tools: imageTool.tools })
: options);
// The same dispatcher the assistant uses, so an image generated here is
// owned, queued and rendered exactly as one generated there.
if (wantsImages && imageModel) {
ai = await imageTool.dispatch(ai, {
owner: req.user.id, workflow: 'my_resources', body: req.body,
imageContext: topic, imageModel: imageModel,
messages: messages, options: options, callAI: callAI
});
}
var markdown = String((ai && ai.content) || '').trim();
if (!markdown) return res.status(502).json({ error: 'The model returned nothing. Try again.' });
@ -122,6 +180,7 @@ router.post('/my-resources/generate', async function (req, res) {
resource: row,
markdown: markdown,
grounding: { used: Boolean(corpus.context), count: corpus.sources.length, reason: corpus.reason || null },
imageJobs: ai.imageJobs || [],
model: ai && ai.model
});
} catch (err) {
@ -197,7 +256,7 @@ router.post('/my-resources/:id/refine', async function (req, res) {
'Keep the same overall structure unless the instruction asks otherwise, and keep any ' +
'References section at the end.\n\nINSTRUCTION: ' + instructions +
'\n\nMARKDOWN:\n"""\n' + existing.markdown + '\n"""' }],
{ model: req.body.model || undefined, temperature: 0.2 });
{ model: await resolveModel(req.body.model), temperature: 0.2 });
var revised = String((ai && ai.content) || '').trim();
if (!revised) return res.status(502).json({ error: 'The model returned nothing. Try again.' });
@ -226,6 +285,12 @@ router.get('/my-resources/:id/export', async function (req, res) {
);
if (!row) return res.status(404).json({ error: 'Not found' });
// The UI does not offer it, but the route is the boundary that matters:
// rendering an article as slides produces a deck of paragraphs.
if (row.kind === 'article' && format === 'pptx') {
return res.status(400).json({ error: 'An article has no slides. Download it as Word or PDF.' });
}
var bytes = await documentExport.render(row.markdown, row.kind, format);
res.setHeader('Content-Type', documentExport.mimeFor(format));
res.setHeader('Content-Disposition',

View file

@ -16,6 +16,7 @@ var fsp = require('fs/promises');
var os = require('os');
var pathMod = require('path');
var { execFile } = require('child_process');
var JSZip = require('jszip');
var REFERENCE_DECK = pathMod.join(__dirname, '..', '..', 'assets', 'learning', 'slides-reference.pptx');
var GOTENBERG = process.env.GOTENBERG_URL || 'http://gotenberg:3000';
@ -29,6 +30,42 @@ var FORMATS = {
function isSupported(format) { return Object.hasOwn(FORMATS, String(format)); }
function mimeFor(format) { return (FORMATS[format] || {}).mime; }
/**
* Let a slide shrink its own text rather than spilling off the bottom.
*
* pandoc writes a bare <a:bodyPr/> on every shape, which leaves the body with no
* autofit even though the slide master has one so a slide with too much on it
* is simply cut off mid-sentence, and the remaining bullets are not rendered at
* all. Verified by rendering one: three of eight bullets survived.
*
* <a:normAutofit/> with no scale asks the renderer to work out the reduction
* itself, which means a slide that already fits is untouched. A fixed
* fontScale would shrink every slide whether it needed it or not.
*
* This is a floor, not a substitute for slides that are the right length the
* prompt still asks for one idea per slide. It stops a long one becoming
* unreadable rather than making overcrowding acceptable.
*/
async function fitSlideText(bytes) {
try {
var zip = await JSZip.loadAsync(bytes);
var slides = Object.keys(zip.files).filter(function (name) {
return /^ppt\/slides\/slide\d+\.xml$/.test(name);
});
if (!slides.length) return bytes;
for (var i = 0; i < slides.length; i++) {
var xml = await zip.file(slides[i]).async('string');
if (xml.indexOf('normAutofit') !== -1) continue;
zip.file(slides[i], xml.replace(/<a:bodyPr\s*\/>/g, '<a:bodyPr><a:normAutofit/></a:bodyPr>'));
}
return await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
} catch (e) {
// A deck that renders imperfectly beats no deck at all.
console.warn('[export] could not apply slide autofit:', e.message);
return bytes;
}
}
function runPandoc(args, cwd) {
return new Promise(function (resolve, reject) {
execFile('pandoc', args, { cwd: cwd, timeout: 60000, maxBuffer: 1024 * 1024 },
@ -69,12 +106,14 @@ async function render(markdown, kind, format) {
if (format === 'pptx') otherArgs.splice(1, 0, '--reference-doc=' + REFERENCE_DECK);
await runPandoc(otherArgs, workdir);
}
return await fsp.readFile(pathMod.join(workdir, 'doc.' + format));
var built = await fsp.readFile(pathMod.join(workdir, 'doc.' + format));
return format === 'pptx' ? await fitSlideText(built) : built;
}
// PDF: hand the office file to Gotenberg. Its LibreOffice keeps the deck's
// layout, which is why this is not rendered from the markdown directly.
var bytes = await fsp.readFile(pathMod.join(workdir, 'doc.' + office));
if (office === 'pptx') bytes = await fitSlideText(bytes);
var form = new FormData();
form.append('files', new File([bytes], 'doc.' + office, { type: FORMATS[office].mime }));
var response = await fetch(GOTENBERG + '/forms/libreoffice/convert', {

View file

@ -96,9 +96,9 @@ test('the screen is reachable by anyone signed in, and states that it is private
assert.match(component, /Nobody else sees these/);
});
test('a row offers all three formats, and the download carries its auth', () => {
test('a row offers the right formats, and the download carries its auth', () => {
const js = read('public/js/myResources.js');
assert.match(js, /\['pptx', 'docx', 'pdf'\]\.forEach/);
assert.match(js, /formats\.forEach\(function \(format\)/);
// An <a href> cannot carry the Authorization header, so the file is fetched
// and saved from a blob instead of linked.
assert.match(js, /headers: getAuthHeaders\(\)/);
@ -109,3 +109,68 @@ test('a row offers all three formats, and the download carries its auth', () =>
assert.match(js, /title\.textContent = row\.title \|\| 'Untitled';/);
assert.doesNotMatch(js, /innerHTML\s*=\s*[^'"]*row\./, 'never interpolated into innerHTML');
});
test('users pick from the models an admin already approved, and nothing else', () => {
const route = read('src/routes/myResources.js');
// One allow-list, the one chat already uses. A second would be another thing
// to keep in step, and would let this reach a model nobody approved.
assert.match(route, /db\.getSetting\('clinical_assistant\.allowed_models', ''\)/);
assert.match(route, /db\.getSetting\('clinical_assistant\.chat_model', ''\)/);
// A stale option in an open browser tab must not cost someone their
// generation, so an unknown model falls back rather than being refused.
assert.match(route, /return wanted && models\.allowed\.indexOf\(wanted\) !== -1 \? wanted : \(models\.configured \|\| undefined\);/);
// Refining goes through the same resolution, not req.body.model directly.
assert.doesNotMatch(route, /model: req\.body\.model \|\| undefined/);
// And the screen only asks when there is a real choice to make.
const js = read('public/js/myResources.js');
assert.match(js, /if \(modelRow\) modelRow\.hidden = models\.length < 2;/);
});
test('illustration is opt-in, and reuses the assistants image tool', () => {
const route = read('src/routes/myResources.js');
// A model handed a drawing tool will find a reason to use it, so the tool is
// only offered when the author asked for one.
assert.match(route, /var wantsImages = String\(req\.body\.withImages\) === 'true'/);
assert.match(route, /wantsImages && imageModel\s*\n?\s*\? Object\.assign\(\{\}, options, \{ tools: imageTool\.tools \}\)/);
// The same dispatcher the assistant uses, so an image made here is owned,
// queued and rendered identically to one made there.
assert.match(route, /imageTool\.dispatch\(ai, \{/);
assert.match(route, /workflow: 'my_resources'/, 'but attributed to this feature');
assert.match(route, /imageJobs: ai\.imageJobs \|\| \[\]/, 'and reported back');
// The row is hidden entirely when no image model is configured.
assert.match(read('public/js/myResources.js'), /if \(imagesRow\) imagesRow\.hidden = !data\.imagesAvailable;/);
assert.match(read('public/components/my-resources.html'), /Off by default: a model handed a drawing tool/);
});
test('a slide shrinks its text rather than spilling off the bottom', () => {
// pandoc writes a bare <a:bodyPr/> on every shape, which leaves the body with
// no autofit even though the slide master has one. Rendered and counted: a
// slide with eight bullets showed three and cut the third mid-sentence, and
// the remaining five were not on the slide at all.
const exporter = read('src/utils/documentExport.js');
assert.match(exporter, /async function fitSlideText\(bytes\)/);
assert.match(exporter, /<a:bodyPr><a:normAutofit\/><\/a:bodyPr>/);
// No fontScale: the renderer works out the reduction, so a slide that already
// fits is left alone. A fixed scale would shrink every slide regardless.
assert.doesNotMatch(exporter, /normAutofit fontScale/);
// Running it on a deck that already has autofit must not double-inject.
assert.match(exporter, /if \(xml\.indexOf\('normAutofit'\) !== -1\) continue;/);
// And it applies to the PDF path too, which renders from the pptx.
assert.match(exporter, /if \(office === 'pptx'\) bytes = await fitSlideText\(bytes\);/);
// A deck that renders imperfectly beats no deck at all.
assert.match(exporter, /could not apply slide autofit/);
assert.ok(JSON.parse(read('package.json')).dependencies.jszip, 'jszip is declared, not borrowed');
});
test('an article is never offered as slides', () => {
const js = read('public/js/myResources.js');
const route = read('src/routes/myResources.js');
// A deck of paragraphs is not a presentation. Word and PDF are fine for
// either; PowerPoint only makes sense for something written as slides.
assert.match(js, /row\.kind === 'article' \? \['docx', 'pdf'\] : \['pptx', 'docx', 'pdf'\]/);
// The route is the boundary that matters, not the button.
assert.match(route, /if \(row\.kind === 'article' && format === 'pptx'\)/);
assert.match(route, /An article has no slides\. Download it as Word or PDF\./);
});