feat: slides are built by pandoc from markdown, with a reference template
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 57s
Forgejo Docker Build / Root app tests (push) Successful in 53s
Forgejo Android APK / Build signed APK (push) Successful in 1m59s
Forgejo Docker Build / Build Docker image (push) Has been cancelled
Forgejo Docker Build / Deploy to the host (push) Has been cancelled

pptxgenjs is gone, and with it 269 lines of hand-rolled markdown parsing.

It stretched every image. Reading the slide XML it emitted shows why: it writes
the target box verbatim with <a:stretch/> and a no-op srcRect, so a 200x800
image handed an 11.8x3.9 box came out 1:4 squashed to 3:1. It could not do
better — it never measures an image, and its own getSizeFromImage is commented
out and marked "currently unused", reaching for a package called sizeof that
does not exist. pandoc measures them: a 300x175 source renders at aspect 1.714
and a 160x360 at 0.445, verified by rendering the deck to PDF and looking at it.

Tables, ordered and unordered lists, bold, italic and subscripts all come out
natively, and the fonts, palette and slide layouts come from
assets/learning/slides-reference.pptx. Design now lives in that file: restyling
the decks means editing it in PowerPoint, not editing this route.

Only images the requester owns can reach a deck. pandoc resolves an image link
against the filesystem, so a markdown link naming any local path would read that
file into the presentation. Images are fetched by id through the ownership
check, written into a per-request temporary directory under names we choose, and
every image link that did not resolve is removed rather than passed through. The
directory is removed in a finally block, and the conversion has a 60s timeout so
it cannot hang a request.

pandoc is in the image rather than a sidecar, because an export must not fail
for reasons outside this container. It costs 197MB (307 -> 504).

Removing pptxgenjs also removed image-size, and with it both high-severity
advisories — GHSA-w3rx-r6r6-pgpr and GHSA-5p2g-fcmc-qvqq, ICNS/JXL/HEIF parser
denial of service, ranged <=2.0.2 with no fixed release to upgrade to. npm audit
goes from 2 high and 2 moderate to 2 moderate.

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 13:25:24 +02:00
parent 61da9e6bf2
commit 15a8b399ba
7 changed files with 93 additions and 389 deletions

View file

@ -13,7 +13,13 @@ WORKDIR /app
# ffmpeg: audio conversion for AWS Transcribe (WebM → PCM)
# curl: HTTP helper used by the OpenBao entrypoint and health/debug tooling
# jq: JSON parsing for the entrypoint's OpenBao secret-fetch step
RUN apk add --no-cache ffmpeg curl jq
# pandoc: Markdown → PPTX for Learning resources. It is large (~230MB), and it
# is here rather than in a sidecar because a sidecar would add a
# cross-stack network dependency to an export that must not fail for
# reasons outside this container. It also measures images, which
# pptxgenjs cannot: that library emits the target box verbatim with
# <a:stretch/>, so every image in every generated deck was distorted.
RUN apk add --no-cache ffmpeg curl jq pandoc-cli
# Pull the bao CLI out of the upstream image — matches host arch because
# buildx pulls the right manifest-list variant per build.

Binary file not shown.

47
package-lock.json generated
View file

@ -26,7 +26,6 @@
"express": "^4.21.0",
"express-rate-limit": "^7.4.0",
"helmet": "^8.0.0",
"image-size": "^2.0.2",
"jsonwebtoken": "^9.0.2",
"katex": "^0.18.7",
"mammoth": "^1.8.0",
@ -39,7 +38,6 @@
"openid-client": "^6.8.2",
"pdf-parse": "^1.1.1",
"pg": "^8.13.0",
"pptxgenjs": "^4.0.1",
"prom-client": "^15.1.3",
"qrcode": "^1.5.4",
"redis": "^4.7.1",
@ -5056,12 +5054,6 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/https": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/https/-/https-1.0.0.tgz",
"integrity": "sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg==",
"license": "ISC"
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
@ -5122,18 +5114,6 @@
"node": ">=0.10.0"
}
},
"node_modules/image-size": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/image-size/-/image-size-2.0.2.tgz",
"integrity": "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==",
"license": "MIT",
"bin": {
"image-size": "bin/image-size.js"
},
"engines": {
"node": ">=16.x"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
@ -6481,33 +6461,6 @@
"node": ">=0.10.0"
}
},
"node_modules/pptxgenjs": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/pptxgenjs/-/pptxgenjs-4.0.1.tgz",
"integrity": "sha512-TeJISr8wouAuXw4C1F/mC33xbZs/FuEG6nH9FG1Zj+nuPcGMP5YRHl6X+j3HSUnS1f3at6k75ZZXPMZlA5Lj9A==",
"license": "MIT",
"dependencies": {
"@types/node": "^22.8.1",
"https": "^1.0.0",
"image-size": "^1.2.1",
"jszip": "^3.10.1"
}
},
"node_modules/pptxgenjs/node_modules/@types/node": {
"version": "22.19.15",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
"integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/pptxgenjs/node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"license": "MIT"
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",

View file

@ -34,7 +34,6 @@
"express": "^4.21.0",
"express-rate-limit": "^7.4.0",
"helmet": "^8.0.0",
"image-size": "^2.0.2",
"jsonwebtoken": "^9.0.2",
"katex": "^0.18.7",
"mammoth": "^1.8.0",
@ -47,7 +46,6 @@
"openid-client": "^6.8.2",
"pdf-parse": "^1.1.1",
"pg": "^8.13.0",
"pptxgenjs": "^4.0.1",
"prom-client": "^15.1.3",
"qrcode": "^1.5.4",
"redis": "^4.7.1",
@ -77,8 +75,7 @@
},
"speech-rule-engine": {
"@xmldom/xmldom": "0.9.12"
},
"image-size": "^2.0.2"
}
},
"engines": {
"node": "24.x"

View file

@ -551,314 +551,60 @@ router.post('/webdav-path', require('../utils/policy').requireFeature('nextcloud
});
// ── POST /api/admin/learning/generate-pptx ──────────────────
// Fit an image inside a box without distorting it.
//
// PowerPoint scales an image to whatever extent it is given, so passing the box
// straight through stretches anything whose aspect ratio differs — which is
// every generated image, since they are square or portrait and the content area
// is wide. pptxgenjs offers a `sizing` option that looks like it solves this,
// but it emits the box dimensions unchanged with <a:stretch/>; it cannot do
// better, because it never measures the image.
//
// So measure it here and hand PowerPoint an extent that already has the right
// shape, centred in the space available. An image that cannot be measured keeps
// the old behaviour rather than failing an export.
// The parsers this application will run, and only these.
//
// image-size ships twenty formats. Three of them — ICNS, JXL and HEIF — have
// open denial-of-service advisories against every published version
// (GHSA-w3rx-r6r6-pgpr, GHSA-5p2g-fcmc-qvqq): a crafted file spins forever in
// the parser. There is no fixed release to upgrade to, so the answer is not to
// run them. Slide images come from the generator as PNG, JPEG or WebP.
//
// Done once, at load, rather than per call: disableTypes replaces the enabled
// set, so calling it repeatedly would be wasted work and easy to get wrong.
var imageSizeReady = false;
function measurer() {
var lib = require('image-size');
if (!imageSizeReady) {
var keep = { png: 1, jpg: 1, webp: 1, gif: 1 };
lib.disableTypes((lib.types || []).filter(function (t) { return !keep[t]; }));
imageSizeReady = true;
}
return lib.imageSize;
}
function fitImage(bytes, x, y, maxW, maxH) {
var fallback = { x: x, y: y, w: maxW, h: maxH };
if (!bytes || !bytes.length) return fallback;
try {
var dims = measurer()(bytes);
if (!dims || !dims.width || !dims.height) return fallback;
var scale = Math.min(maxW / dims.width, maxH / dims.height);
var w = dims.width * scale;
var h = dims.height * scale;
return { x: x + (maxW - w) / 2, y: y + (maxH - h) / 2, w: w, h: h };
} catch (e) {
return fallback;
}
}
// Convert Marp markdown to PPTX using pptxgenjs (no browser needed)
// pandoc measures images itself and emits an extent with the right shape, so
// nothing here needs to size them.
router.post('/generate-pptx', async function(req, res) {
var workdir = null;
try {
var { markdown, title } = req.body;
if (!markdown) return res.status(400).json({ error: 'markdown required' });
var assetImages = {};
// Kept alongside the data URI so the image can be measured. pptxgenjs cannot
// do it: its sizing option emits the box dimensions verbatim with
// <a:stretch/>, so a portrait image handed to a landscape box comes out
// stretched. Its own getSizeFromImage is commented out and marked unused.
var assetImageBytes = {};
for (const id of require('../utils/generatedImageLinks').references(markdown)) {
const image = await require('../utils/generatedImages').service().asset(id, req.user);
assetImages['/api/generated-images/' + id] = 'data:' + image.mime + ';base64,' + image.bytes.toString('base64');
assetImageBytes['/api/generated-images/' + id] = image.bytes;
}
var PptxGenJS = require('pptxgenjs');
var pptx = new PptxGenJS();
var fsp = require('fs/promises');
var os = require('os');
var pathMod = require('path');
var { execFile } = require('child_process');
pptx.layout = 'LAYOUT_WIDE'; // 16:9
workdir = await fsp.mkdtemp(pathMod.join(os.tmpdir(), 'pptx-'));
// ── Helpers ────────────────────────────────────────────────
// Parse inline markdown (**bold**, *italic*, `code`, ***both***) into pptxgenjs text objects
function parseInline(text, defaults) {
var parts = [];
var re = /(\*\*\*(.+?)\*\*\*|\*\*(.+?)\*\*|\*(.+?)\*|`(.+?)`)/g;
var last = 0;
var m;
while ((m = re.exec(text)) !== null) {
if (m.index > last) parts.push({ text: text.slice(last, m.index), options: Object.assign({}, defaults) });
if (m[2]) parts.push({ text: m[2], options: Object.assign({}, defaults, { bold: true, italic: true }) });
else if (m[3]) parts.push({ text: m[3], options: Object.assign({}, defaults, { bold: true }) });
else if (m[4]) parts.push({ text: m[4], options: Object.assign({}, defaults, { italic: true }) });
else if (m[5]) parts.push({ text: m[5], options: Object.assign({}, defaults, { fontFace: 'Courier New', color: '7c3aed' }) });
last = m.index + m[0].length;
}
if (last < text.length) parts.push({ text: text.slice(last), options: Object.assign({}, defaults) });
return parts.length ? parts : [{ text: text, options: Object.assign({}, defaults) }];
// Only images this user owns, written beside the markdown under names we
// choose. Everything else is stripped: pandoc resolves an image link
// against the filesystem, so a link naming any local path would embed that
// file into the deck. The allow-list is the set we just fetched by id.
var allowed = Object.create(null);
var refs = require('../utils/generatedImageLinks').references(markdown);
for (var r = 0; r < refs.length; r++) {
var id = refs[r];
var image = await require('../utils/generatedImages').service().asset(id, req.user);
var ext = String(image.mime || '').indexOf('png') !== -1 ? 'png' : 'jpg';
var file = 'img-' + r + '.' + ext;
await fsp.writeFile(pathMod.join(workdir, file), image.bytes);
allowed['/api/generated-images/' + id] = file;
}
// Parse a markdown table block into { headers: string[], rows: string[][] }
function parseTable(lines) {
var headers = lines[0].split('|').map(function(c) { return c.trim(); }).filter(Boolean);
var rows = [];
for (var i = 2; i < lines.length; i++) {
var cells = lines[i].split('|').map(function(c) { return c.trim(); }).filter(Boolean);
if (cells.length) rows.push(cells);
}
return { headers: headers, rows: rows };
}
// Classify a line by type
function classifyLine(line) {
if (/^!\[[^\]]*\]\(\/api\/generated-images\/[0-9a-f-]{36}\)$/.test(line.trim())) return 'image';
if (/^\|.+\|/.test(line)) return 'table';
if (/^\|[\s:-]+\|/.test(line)) return 'table-sep';
if (/^>\s+/.test(line)) return 'blockquote';
if (/^```/.test(line)) return 'code-fence';
if (/^\d+\.\s+/.test(line)) return 'ordered';
if (/^[-*]\s+/.test(line)) return 'bullet';
if (/^#{1,3}\s+/.test(line)) return 'heading';
if (line.trim() === '') return 'blank';
return 'paragraph';
}
// ── Parse slides ───────────────────────────────────────────
var md = markdown.replace(/^---[\s\S]*?---\n?/, '').trim();
var rawSlides = md.split(/\n---\n/);
rawSlides.forEach(function(slideText, slideIdx) {
slideText = slideText.trim();
if (!slideText) return;
var slide = pptx.addSlide();
slide.background = { color: 'FFFFFF' };
// Extract slide title (first # heading)
var titleMatch = slideText.match(/^#{1,3}\s+(.+)$/m);
var slideTitle = titleMatch ? titleMatch[1].trim() : '';
var body = slideText.replace(/^#{1,3}\s+.+$/m, '').trim();
if (slideTitle) {
slide.addText(parseInline(slideTitle, { fontSize: 28, bold: true, color: '1e40af', fontFace: 'Calibri' }), {
x: 0.5, y: 0.3, w: '90%', h: 0.8, valign: 'middle'
});
}
if (!body) {
slide.addText((slideIdx + 1).toString(), { x: '90%', y: '92%', w: '8%', h: 0.3, fontSize: 9, color: 'bbbbbb', align: 'right', fontFace: 'Calibri' });
return;
}
var contentY = slideTitle ? 1.3 : 0.6;
var maxH = 5.2 - contentY;
var lines = body.split('\n');
var i = 0;
var inCodeBlock = false;
var codeLines = [];
while (i < lines.length) {
var line = lines[i];
var type = classifyLine(line);
if (type === 'image') {
var src = line.trim().match(/\(([^)]+)\)$/)[1];
if (!assetImages[src]) throw new Error('Generated image unavailable');
var box = fitImage(assetImageBytes[src], 0.5, contentY, 11.8, Math.max(0.5, 5.2 - contentY));
slide.addImage({ data: assetImages[src], x: box.x, y: box.y, w: box.w, h: box.h });
contentY = 5.2; i++; continue;
}
// ── Code block ──
if (type === 'code-fence' || inCodeBlock) {
if (type === 'code-fence' && !inCodeBlock) {
inCodeBlock = true; codeLines = []; i++; continue;
}
if (type === 'code-fence' && inCodeBlock) {
inCodeBlock = false;
if (codeLines.length) {
var codeText = codeLines.join('\n');
var codeH = Math.min(Math.max(codeLines.length * 0.28, 0.6), maxH - (contentY - (slideTitle ? 1.3 : 0.6)));
slide.addShape(pptx.ShapeType.rect, { x: 0.5, y: contentY, w: '90%', h: codeH, fill: { color: 'f3f4f6' }, rectRadius: 0.08 });
slide.addText(codeText, {
x: 0.65, y: contentY + 0.08, w: '86%', h: codeH - 0.16,
fontSize: 13, fontFace: 'Courier New', color: '1f2937', valign: 'top', wrap: true
});
contentY += codeH + 0.2;
codeLines = [];
}
i++; continue;
}
codeLines.push(line);
i++; continue;
}
// ── Table ──
if (type === 'table') {
var tableLines = [];
while (i < lines.length && /^\|/.test(lines[i])) { tableLines.push(lines[i]); i++; }
var tbl = parseTable(tableLines);
if (tbl.headers.length) {
var colW = (12 / tbl.headers.length);
var tblRows = [];
// Header row
tblRows.push(tbl.headers.map(function(h) {
return { text: h, options: { bold: true, fontSize: 13, color: 'ffffff', fill: { color: '1e40af' }, fontFace: 'Calibri', align: 'center', valign: 'middle' } };
}));
// Data rows
tbl.rows.forEach(function(row, ri) {
tblRows.push(row.map(function(cell) {
return { text: cell, options: { fontSize: 12, color: '374151', fill: { color: ri % 2 === 0 ? 'f9fafb' : 'ffffff' }, fontFace: 'Calibri', valign: 'middle' } };
}));
});
var tblH = Math.min((tblRows.length * 0.38) + 0.1, maxH - (contentY - (slideTitle ? 1.3 : 0.6)));
slide.addTable(tblRows, {
x: 0.5, y: contentY, w: 12,
colW: Array(tbl.headers.length).fill(colW),
border: { type: 'solid', pt: 0.5, color: 'dee2e6' },
rowH: 0.36,
autoPage: false
});
contentY += tblH + 0.15;
}
continue;
}
// ── Blockquote ──
if (type === 'blockquote') {
var quoteText = line.replace(/^>\s*/, '').trim();
i++;
while (i < lines.length && /^>\s*/.test(lines[i])) { quoteText += '\n' + lines[i].replace(/^>\s*/, '').trim(); i++; }
var qH = Math.max(0.5, Math.ceil(quoteText.length / 100) * 0.35);
slide.addShape(pptx.ShapeType.rect, { x: 0.5, y: contentY, w: '90%', h: qH, fill: { color: 'eff6ff' }, rectRadius: 0.06 });
slide.addShape(pptx.ShapeType.rect, { x: 0.5, y: contentY, w: 0.06, h: qH, fill: { color: '3b82f6' } });
slide.addText(parseInline(quoteText, { fontSize: 16, italic: true, color: '1e40af', fontFace: 'Calibri' }), {
x: 0.8, y: contentY + 0.06, w: '85%', h: qH - 0.12, valign: 'middle', wrap: true
});
contentY += qH + 0.15;
continue;
}
// ── Bullets (unordered) ──
if (type === 'bullet') {
var bulletItems = [];
while (i < lines.length && /^[-*]\s+/.test(lines[i])) {
bulletItems.push(lines[i].replace(/^[-*]\s+/, '').trim());
i++;
}
var bulletObjs = [];
bulletItems.forEach(function(b) {
var inlineParts = parseInline(b, { fontSize: 17, color: '374151', fontFace: 'Calibri' });
inlineParts[0].options.bullet = { type: 'bullet' };
inlineParts[0].options.paraSpaceBefore = 4;
bulletObjs.push(inlineParts);
});
var flat = []; bulletObjs.forEach(function(arr) { arr.forEach(function(p) { flat.push(p); }); });
var bH = Math.min(Math.max(bulletItems.length * 0.35, 0.6), maxH - (contentY - (slideTitle ? 1.3 : 0.6)));
slide.addText(flat, { x: 0.6, y: contentY, w: '88%', h: bH, fontFace: 'Calibri', valign: 'top' });
contentY += bH + 0.1;
continue;
}
// ── Numbered list ──
if (type === 'ordered') {
var orderedItems = [];
while (i < lines.length && /^\d+\.\s+/.test(lines[i])) {
orderedItems.push(lines[i].replace(/^\d+\.\s+/, '').trim());
i++;
}
var orderedObjs = [];
orderedItems.forEach(function(item, idx) {
var inlineParts = parseInline(item, { fontSize: 17, color: '374151', fontFace: 'Calibri' });
inlineParts[0].options.bullet = { type: 'number', numberStartAt: idx === 0 ? 1 : undefined };
inlineParts[0].options.paraSpaceBefore = 4;
orderedObjs.push(inlineParts);
});
var flatOrd = []; orderedObjs.forEach(function(arr) { arr.forEach(function(p) { flatOrd.push(p); }); });
var oH = Math.min(Math.max(orderedItems.length * 0.35, 0.6), maxH - (contentY - (slideTitle ? 1.3 : 0.6)));
slide.addText(flatOrd, { x: 0.6, y: contentY, w: '88%', h: oH, fontFace: 'Calibri', valign: 'top' });
contentY += oH + 0.1;
continue;
}
// ── Sub-heading (## or ### inside body) ──
if (type === 'heading') {
var hText = line.replace(/^#{1,3}\s+/, '').trim();
slide.addText(parseInline(hText, { fontSize: 22, bold: true, color: '1e3a5f', fontFace: 'Calibri' }), {
x: 0.5, y: contentY, w: '90%', h: 0.45, valign: 'bottom'
});
contentY += 0.5;
i++; continue;
}
// ── Paragraph ──
if (type === 'paragraph') {
var paraText = line.trim();
i++;
// Merge consecutive paragraph lines
while (i < lines.length && classifyLine(lines[i]) === 'paragraph') { paraText += ' ' + lines[i].trim(); i++; }
var pParts = parseInline(paraText, { fontSize: 17, color: '374151', fontFace: 'Calibri' });
var pH = Math.max(0.4, Math.ceil(paraText.length / 110) * 0.3);
slide.addText(pParts, { x: 0.5, y: contentY, w: '90%', h: pH, valign: 'top', wrap: true });
contentY += pH + 0.1;
continue;
}
// ── Blank / skip ──
i++;
}
// Slide number
slide.addText((slideIdx + 1).toString(), { x: '90%', y: '92%', w: '8%', h: 0.3, fontSize: 9, color: 'bbbbbb', align: 'right', fontFace: 'Calibri' });
var source = String(markdown).replace(/!\[([^\]]*)\]\(([^)]+)\)/g, function (whole, alt, src) {
var local = allowed[String(src).trim()];
// A link we did not resolve is dropped rather than passed through, so a
// deck can never be made to read a path off this host.
return local ? '![' + alt + '](' + local + ')' : '';
});
// Write to buffer and send
var pptxBuffer = await pptx.write({ outputType: 'nodebuffer' });
await fsp.writeFile(pathMod.join(workdir, 'deck.md'), source, 'utf8');
// The reference deck carries the fonts, palette and slide layouts. Design
// lives there, not here: restyling means editing that file in PowerPoint.
var reference = pathMod.join(__dirname, '..', '..', 'assets', 'learning', 'slides-reference.pptx');
await new Promise(function (resolve, reject) {
execFile('pandoc', ['deck.md', '--reference-doc=' + reference, '-o', 'deck.pptx'],
{ cwd: workdir, timeout: 60000, maxBuffer: 1024 * 1024 },
function (err, stdout, stderr) {
if (err) return reject(new Error(String(stderr || err.message).slice(0, 400)));
resolve();
});
});
var pptxBuffer = await fsp.readFile(pathMod.join(workdir, 'deck.pptx'));
var safeTitle = (title || 'presentation').replace(/[^a-zA-Z0-9-_\s]/g, '').replace(/\s+/g, '-').toLowerCase();
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.presentationml.presentation');
@ -868,6 +614,11 @@ router.post('/generate-pptx', async function(req, res) {
} catch (err) {
console.error('[PPTX]', err.message);
res.status(500).json({ error: 'Request failed' });
} finally {
if (workdir) {
try { await require('fs/promises').rm(workdir, { recursive: true, force: true }); }
catch (e) { console.warn('[PPTX] could not clean', workdir, e.message); }
}
}
});

View file

@ -286,50 +286,47 @@ test('citation quality is measured on the server, where answer and sources both
assert.match(route, /tracker\.store\(req\.user\.id, question, result, sources\);/);
});
test('PPTX images keep their shape', () => {
// PowerPoint scales an image to whatever extent it is given. pptxgenjs offers
// a `sizing` option that looks like it handles this, but it emits the box
// dimensions unchanged with <a:stretch/> — verified by reading the slide XML:
// a 200x800 image in an 11.8x3.9 box came out as cx=10789920 cy=3566160,
// i.e. stretched from 1:4 to 3:1. It cannot do better, because it never
// measures the image; its own getSizeFromImage is commented out as unused.
test('slides are built by pandoc, from markdown, with a reference template', () => {
// pptxgenjs is gone. It stretched every image — reading the slide XML it
// emitted showed the target box verbatim with <a:stretch/>, so a 200x800
// image in an 11.8x3.9 box came out 1:4 squashed to 3:1 — and it could not
// do better, because it never measures an image; its own getSizeFromImage is
// commented out as unused. pandoc measures them: rendered and inspected, a
// 300x175 source produced aspect 1.714 and a 160x360 produced 0.445.
//
// Removing it also took image-size with it, and with that both high-severity
// advisories (GHSA-w3rx-r6r6-pgpr, GHSA-5p2g-fcmc-qvqq), which had no fixed
// release to upgrade to.
const src = read('src/routes/learningAI.js');
assert.match(src, /function fitImage\(bytes, x, y, maxW, maxH\)/);
assert.match(src, /var dims = measurer\(\)\(bytes\);/, 'measured here instead');
assert.match(src, /var scale = Math\.min\(maxW \/ dims\.width, maxH \/ dims\.height\);/,
'scaled to fit inside the box on whichever axis binds first');
assert.match(src, /x: x \+ \(maxW - w\) \/ 2, y: y \+ \(maxH - h\) \/ 2/, 'and centred in it');
// An image that cannot be measured must not fail the export.
assert.match(src, /var fallback = \{ x: x, y: y, w: maxW, h: maxH \};/);
assert.match(src, /catch \(e\) \{\s*\n\s*return fallback;/);
// The sizing option is gone: leaving it would only re-stretch what fitImage
// just measured.
const placement = src.slice(src.indexOf("if (type === 'image')"), src.indexOf("// ── Code block ──"));
assert.doesNotMatch(placement, /sizing:/, 'no sizing option fighting the measured box');
assert.match(placement, /slide\.addImage\(\{ data: assetImages\[src\], x: box\.x, y: box\.y, w: box\.w, h: box\.h \}\)/);
assert.doesNotMatch(src, /pptxgenjs|PptxGenJS/, 'nothing builds slides by hand any more');
assert.match(src, /execFile\('pandoc', \['deck\.md', '--reference-doc=' \+ reference, '-o', 'deck\.pptx'\]/);
assert.match(src, /timeout: 60000/, 'a conversion cannot hang the request');
// Design lives in the template, so restyling is editing a file in PowerPoint.
assert.match(src, /assets', 'learning', 'slides-reference\.pptx'/);
assert.ok(require('node:fs').existsSync(
require('node:path').join(__dirname, '..', 'assets', 'learning', 'slides-reference.pptx')),
'and that template ships with the app');
// pandoc must be in the image, or every export fails at runtime.
assert.match(read('Dockerfile'), /apk add --no-cache ffmpeg curl jq pandoc-cli/);
// image-size is a real dependency now, and only one copy of it exists.
const pkg = JSON.parse(read('package.json'));
assert.ok(pkg.dependencies['image-size'], 'declared directly rather than borrowed transitively');
assert.equal(pkg.overrides['image-size'], '^2.0.2',
'pptxgenjs declares it but never requires it; without this npm ships a second copy nothing can load');
assert.ok(!pkg.dependencies['pptxgenjs'], 'and the library is no longer a dependency');
assert.ok(!pkg.dependencies['image-size'], 'nor the parser it dragged in');
});
test('only the image parsers this app needs are allowed to run', () => {
// ICNS, JXL and HEIF have open denial-of-service advisories against EVERY
// published image-size — GHSA-w3rx-r6r6-pgpr and GHSA-5p2g-fcmc-qvqq, both
// ranged <=2.0.2. There is no fixed release, so the fix is not to run them.
test('a deck can only embed images the requester owns', () => {
// pandoc resolves an image link against the filesystem, so a markdown link
// naming any local path would read that file into the deck. Only the images
// fetched by id for this user are written into the working directory, and
// every other image link is dropped rather than passed through.
const src = read('src/routes/learningAI.js');
assert.match(src, /var keep = \{ png: 1, jpg: 1, webp: 1, gif: 1 \};/);
assert.match(src, /lib\.disableTypes\(\(lib\.types \|\| \[\]\)\.filter\(function \(t\) \{ return !keep\[t\]; \}\)\)/);
// disableTypes replaces the enabled set, so calling it per measurement would
// be wasted work and easy to get wrong.
assert.match(src, /if \(!imageSizeReady\) \{/, 'done once, at first use');
// And the library actually honours it — this is the contract that could move.
const lib = require('image-size');
const keep = { png: 1, jpg: 1, webp: 1, gif: 1 };
lib.disableTypes((lib.types || []).filter(t => !keep[t]));
assert.throws(() => lib.imageSize(Buffer.concat([Buffer.from('icns'), Buffer.alloc(60)])),
'a disabled parser must refuse rather than run');
assert.match(src, /var image = await require\('\.\.\/utils\/generatedImages'\)\.service\(\)\.asset\(id, req\.user\);/,
'ownership is checked when fetching');
assert.match(src, /return local \? '!\[' \+ alt \+ '\]\(' \+ local \+ '\)' : '';/,
'an unresolved link is removed, not passed to pandoc');
// The working directory is per request and always cleaned up.
assert.match(src, /workdir = await fsp\.mkdtemp\(/);
assert.match(src, /\} finally \{[\s\S]{0,200}rm\(workdir, \{ recursive: true, force: true \}\)/);
});

View file

@ -154,7 +154,7 @@ test('actual authenticated asset/settings and Learning content write routes enfo
const learningAI=load('src/routes/learningAI.js',{
express,multer:require('multer'),axios:{},path:require('path'),'../utils/ai':{},'../utils/imageTool':require('../src/utils/imageTool'),
'../middleware/auth':auth,'../db/database':routeDb,'../utils/crypto':require('../src/utils/crypto'),'../utils/urlSafety':require('../src/utils/urlSafety'),
'../utils/policy':{requireFeature:()=>()=>{}},'../utils/generatedImageLinks':links,'../utils/generatedImages':{service:()=>jobs},'pptxgenjs':require('pptxgenjs')
'../utils/policy':{requireFeature:()=>()=>{}},'../utils/generatedImageLinks':links,'../utils/generatedImages':{service:()=>jobs}
});
await db.query('CREATE TABLE learning_categories(id SERIAL PRIMARY KEY,name TEXT);CREATE TABLE learning_questions(id SERIAL PRIMARY KEY,content_id INTEGER,sort_order INTEGER)');
const clinicalRoutes = load('src/routes/clinicalAssistant.js', {