diff --git a/Dockerfile b/Dockerfile
index 9234fa58..25149fc1 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -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
+# , 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.
diff --git a/assets/learning/slides-reference.pptx b/assets/learning/slides-reference.pptx
new file mode 100644
index 00000000..4665c8c1
Binary files /dev/null and b/assets/learning/slides-reference.pptx differ
diff --git a/package-lock.json b/package-lock.json
index e3535249..68342546 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index f3a54c11..13e01ed6 100644
--- a/package.json
+++ b/package.json
@@ -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"
diff --git a/src/routes/learningAI.js b/src/routes/learningAI.js
index 040e8462..4f2baab5 100644
--- a/src/routes/learningAI.js
+++ b/src/routes/learningAI.js
@@ -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 ; 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
- // , 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 ? '' : '';
});
- // 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); }
+ }
}
});
diff --git a/test/backend-hardening.test.js b/test/backend-hardening.test.js
index bd9a9ddd..c8f88025 100644
--- a/test/backend-hardening.test.js
+++ b/test/backend-hardening.test.js
@@ -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 — 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 , 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 \}\)/);
});
diff --git a/test/generated-images.integration.js b/test/generated-images.integration.js
index 40fa3e45..2e890468 100644
--- a/test/generated-images.integration.js
+++ b/test/generated-images.integration.js
@@ -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', {