diff --git a/package-lock.json b/package-lock.json
index 16c5515d..e3535249 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -26,6 +26,7 @@
"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",
@@ -5122,13 +5123,10 @@
}
},
"node_modules/image-size": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/image-size/-/image-size-1.2.1.tgz",
- "integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==",
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/image-size/-/image-size-2.0.2.tgz",
+ "integrity": "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==",
"license": "MIT",
- "dependencies": {
- "queue": "6.0.2"
- },
"bin": {
"image-size": "bin/image-size.js"
},
@@ -6741,15 +6739,6 @@
"node": ">=10.13.0"
}
},
- "node_modules/queue": {
- "version": "6.0.2",
- "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
- "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
- "license": "MIT",
- "dependencies": {
- "inherits": "~2.0.3"
- }
- },
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
diff --git a/package.json b/package.json
index 1f3a755d..f3a54c11 100644
--- a/package.json
+++ b/package.json
@@ -34,6 +34,7 @@
"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",
@@ -76,7 +77,8 @@
},
"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 5fc56a80..040e8462 100644
--- a/src/routes/learningAI.js
+++ b/src/routes/learningAI.js
@@ -551,6 +551,54 @@ 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)
router.post('/generate-pptx', async function(req, res) {
@@ -559,9 +607,15 @@ router.post('/generate-pptx', async function(req, res) {
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();
@@ -655,7 +709,8 @@ router.post('/generate-pptx', async function(req, res) {
if (type === 'image') {
var src = line.trim().match(/\(([^)]+)\)$/)[1];
if (!assetImages[src]) throw new Error('Generated image unavailable');
- slide.addImage({ data: assetImages[src], x: 0.5, y: contentY, w: 11.8, h: Math.max(0.5, 5.2 - contentY), sizing: { type: 'contain', w: 11.8, h: Math.max(0.5, 5.2 - contentY) } });
+ 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;
}
diff --git a/test/backend-hardening.test.js b/test/backend-hardening.test.js
index fcc102f0..bd9a9ddd 100644
--- a/test/backend-hardening.test.js
+++ b/test/backend-hardening.test.js
@@ -285,3 +285,51 @@ test('citation quality is measured on the server, where answer and sources both
assert.match(route, /if \(!tracker\) return;/);
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.
+ 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 \}\)/);
+
+ // 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');
+});
+
+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.
+ 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');
+});