pediatric-ai-scribe-v3/src/utils/documentExport.js
Daniel af2e09c1de
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 1m7s
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Android APK / Build signed APK (push) Successful in 2m16s
Forgejo Docker Build / Build Docker image (push) Successful in 16s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
feat: a slide can be drawn from primitives when the named layouts have no word for it
The nine layouts are a fixed vocabulary and a good default, but "lay the three
severity levels out left to right with arrows between them" had no expression in
them at all. A "custom" slide now carries a list of shapes: positioned text,
eight autoshape families, lines, images, tables, and native PowerPoint charts —
column, bar, line, pie, doughnut.

Coordinates are percentages of the slide rather than EMU, because a model
reasons about "the left half" and not about 12192000. Shapes draw in array
order, so a later one sits on top.

The model never emits Python. It names shapes and the renderer draws them:
running model-authored code to lay out a slide would be an enormous amount of
trust to buy a feature, on a server holding clinical data and secrets.

Validation lives beside the text that teaches the vocabulary, in one file, so
what the model is told about is exactly what is accepted. Kinds are an
allowlist, colours must be six hex digits, coordinates are clamped inside the
slide — a shape at x=95 w=30 is cut to the edge rather than drawn half off it —
counts are capped, a pie is held to one series, and anything that cannot be
understood is dropped. A custom slide that loses every shape becomes a plain one
rather than a heading over an empty frame, and one bad shape is caught in the
renderer so it cannot cost the slide it sits on.

A figure on a custom slide is requested through an image shape, drawn by the
same path as any other, and attached by job id. Word renders a custom slide as
its words in reading order with its tables and figures — lossy, and better than
dropping the slide.

Verified live end to end: asked to "lay the three severity levels out left to
right as coloured boxes with arrows between them", the model produced
[rect arrow rect arrow rect], chose green/amber/red itself, and the rendered
slide was looked at. A column chart beside its commentary renders with real axes
and gridlines.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 22:12:26 +02:00

264 lines
12 KiB
JavaScript

// ============================================================
// DOCUMENT EXPORT
// Markdown in, PowerPoint / Word / PDF out.
//
// Markdown is the artifact everywhere: it is what the model writes, what a
// refinement edits, and what is stored. Every format here is rendered from it
// on demand, so nothing ever has to patch a binary to change a slide.
//
// pandoc does pptx and docx in the image. PDF needs a renderer pandoc does not
// ship, so it goes to Gotenberg, which is LibreOffice behind an HTTP API. That
// is a network call, and it is the one export allowed to fail: if Gotenberg is
// down the deck and the document still download, and only PDF is unavailable.
// ============================================================
var fsp = require('fs/promises');
var os = require('os');
var pathMod = require('path');
var { execFile, spawn } = require('child_process');
var JSZip = require('jszip');
var slideSpec = require('./slideSpec');
var docSpec = require('./docSpec');
var REFERENCE_DECK = pathMod.join(__dirname, '..', '..', 'assets', 'learning', 'slides-reference.pptx');
var DECK_RENDERER = pathMod.join(__dirname, '..', '..', 'scripts', 'render_pptx.py');
var DOC_RENDERER = pathMod.join(__dirname, '..', '..', 'scripts', 'render_docx.py');
var GOTENBERG = process.env.GOTENBERG_URL || 'http://gotenberg:3000';
var FORMATS = {
pptx: { ext: 'pptx', mime: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' },
docx: { ext: 'docx', mime: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' },
pdf: { ext: 'pdf', mime: 'application/pdf' }
};
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 },
function (err, stdout, stderr) {
if (err) return reject(new Error(String(stderr || err.message).slice(0, 400)));
resolve();
});
});
}
/**
* Render markdown to one format.
*
* `kind` decides how a document is laid out: a presentation becomes slides with
* the reference deck's fonts and layouts, an article becomes a Word document.
* PDF is produced by converting whichever of those two applies, so a PDF of a
* presentation looks like the presentation rather than like a long page.
*
* Returns a Buffer. Throws with a readable message; the caller decides whether
* a failed PDF is fatal.
*/
async function render(markdown, kind, format, options) {
if (!isSupported(format)) throw new Error('Unsupported format: ' + format);
options = options || {};
var workdir = await fsp.mkdtemp(pathMod.join(os.tmpdir(), 'export-'));
try {
await fsp.writeFile(pathMod.join(workdir, 'doc.md'), String(markdown || ''), 'utf8');
var office = kind === 'presentation' ? 'pptx' : 'docx';
if (office === 'docx' || format === 'docx') {
await buildDoc(markdown, workdir, options);
}
if (office === 'pptx' || format === 'pptx') {
await buildDeck(markdown, workdir, options.images || [], options);
}
if (format !== 'pdf') {
return await fsp.readFile(pathMod.join(workdir, 'doc.' + format));
}
// 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));
var form = new FormData();
form.append('files', new File([bytes], 'doc.' + office, { type: FORMATS[office].mime }));
var response = await fetch(GOTENBERG + '/forms/libreoffice/convert', {
method: 'POST', body: form, signal: AbortSignal.timeout(90000)
});
if (!response.ok) throw new Error('PDF conversion failed (' + response.status + ')');
return Buffer.from(await response.arrayBuffer());
} finally {
try { await fsp.rm(workdir, { recursive: true, force: true }); }
catch (e) { console.warn('[export] could not clean', workdir, e.message); }
}
}
// ── Deck ────────────────────────────────────────────────────
// Built by scripts/render_pptx.py rather than pandoc. Pandoc's pptx writer maps
// markdown onto a handful of reference layouts and gives no control over
// per-slide layout, positioning, or how large an image is drawn — the reason
// every generated deck came out as bullets on a template, and the reason
// autofit had to be injected into its output by hand afterwards.
//
// If the renderer fails for any reason, pandoc still produces a deck. A plainer
// deck beats a failed download.
function runRenderer(script, out, spec, workdir) {
return new Promise(function (resolve, reject) {
var child = spawn('python3', [script, out], { cwd: workdir });
var stderr = '';
child.stderr.on('data', function (chunk) { stderr += chunk.toString().slice(0, 2000); });
child.on('error', reject);
child.on('close', function (code) {
if (code === 0) return resolve();
reject(new Error(pathMod.basename(script) + ' exited ' + code + (stderr ? ': ' + stderr.trim() : '')));
});
child.stdin.end(JSON.stringify(spec));
});
}
// ── Word ────────────────────────────────────────────────────
// Also python, for the same reason as the deck. Pandoc reads markdown, so a deck
// had to be flattened 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. Built from the typed spec, a
// comparison is a two-column table, a callout is a shaded box, and a figure
// keeps its caption.
//
// Pandoc remains the fallback. A plainer document beats a failed download.
async function buildDoc(markdown, workdir, options) {
var out = pathMod.join(workdir, 'doc.docx');
try {
var spec = docSpec.build({
deck: options.deck, markdown: markdown,
images: figuresByJob(options.images, options.figureIds)
});
if (!spec.blocks.length) throw new Error('nothing to render');
await runRenderer(DOC_RENDERER, out, spec, workdir);
if (!(await fsp.readFile(out)).length) throw new Error('the renderer produced an empty file');
} catch (err) {
console.warn('[export] document renderer failed, falling back to pandoc:', err.message);
await runPandoc(['doc.md', '-o', 'doc.docx'], workdir);
}
}
function figuresByJob(files, figureIds) {
var byJob = {};
(figureIds || []).forEach(function (id, index) {
if (files && files[index]) byJob[id] = files[index];
});
return byJob;
}
async function buildDeck(markdown, workdir, images, options) {
options = options || {};
var out = pathMod.join(workdir, 'doc.pptx');
try {
// A deck the model designed is rendered as designed. Only a resource made
// before decks existed, or an article being forced into slides, falls back
// to inferring a layout from markdown.
var spec = options.deck
? attachFigures(options.deck, images, options.figureIds)
: slideSpec.build(markdown, { images: images });
await runRenderer(DECK_RENDERER, out, spec, workdir);
var built = await fsp.readFile(out);
if (built.length) return;
throw new Error('deck renderer produced an empty file');
} catch (err) {
console.warn('[export] deck renderer failed, falling back to pandoc:', err.message);
await runPandoc(['doc.md', '--reference-doc=' + REFERENCE_DECK, '-o', 'doc.pptx'], workdir);
// pandoc leaves a bare <a:bodyPr/> on every shape, so its decks still need
// autofit injecting or they overflow.
await fsp.writeFile(out, await fitSlideText(await fsp.readFile(out)));
}
}
// Put the drawn figures back on the slides that asked for them. The files
// arrive in the order the jobs were created, which is the order the slides
// requested them, so a slide is matched by its job id rather than by position.
function attachFigures(deck, files, figureIds) {
var byJob = figuresByJob(files, figureIds);
var slides = (deck.slides || []).map(function (slide) {
var copy = Object.assign({}, slide);
// A custom slide carries its figures on image shapes, under the same rule.
if (Array.isArray(copy.shapes)) {
copy.shapes = copy.shapes.map(function (shape) {
var s = Object.assign({}, shape);
delete s.image;
if (s.image_job && byJob[s.image_job]) s.image = byJob[s.image_job];
return s;
});
}
// The renderer reads whatever path this field holds and embeds that file in
// the download. Nothing today can put a path in a stored deck — normalise()
// never copies one and the edit endpoint writes markdown only — but the
// field is cleared before it is set, so the only paths that can reach the
// renderer are the figures just fetched for this export.
delete copy.image;
if (copy.image_job && byJob[copy.image_job]) copy.image = byJob[copy.image_job];
// A figure slide whose picture never arrived is still a slide of text.
if (!copy.image && (copy.type === 'image' || copy.type === 'figure')) {
copy.type = copy.type === 'image' ? 'section' : 'bullets';
}
return copy;
});
var out = { title: deck.title, subtitle: deck.subtitle, date: deck.date, slides: slides };
if (deck.title && !slides.some(function (s) { return s.type === 'title'; })) {
out.slides = [{ type: 'title', heading: deck.title, subtitle: deck.subtitle, date: deck.date }]
.concat(slides);
}
return out;
}
// A filename someone can find again, without letting a title choose the path.
function filename(title, format) {
var safe = String(title || 'resource')
.replace(/[^a-zA-Z0-9-_\s]/g, '').replace(/\s+/g, '-').toLowerCase().slice(0, 60) || 'resource';
return safe + '.' + (FORMATS[format] || FORMATS.pdf).ext;
}
// The deck as bytes, without writing a download. The reviewer renders the deck
// it is about to judge, and that is the same pipeline an export runs.
async function renderDeck(deck, images, figureIds) {
var workdir = await fsp.mkdtemp(pathMod.join(os.tmpdir(), 'deck-'));
try {
await buildDeck('', workdir, images || [], { deck: deck, figureIds: figureIds || [] });
return await fsp.readFile(pathMod.join(workdir, 'doc.pptx'));
} finally {
await fsp.rm(workdir, { recursive: true, force: true }).catch(function () {});
}
}
module.exports = { render, renderDeck, filename, mimeFor, isSupported, FORMATS, GOTENBERG };