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
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:
parent
61da9e6bf2
commit
15a8b399ba
7 changed files with 93 additions and 389 deletions
|
|
@ -13,7 +13,13 @@ WORKDIR /app
|
||||||
# ffmpeg: audio conversion for AWS Transcribe (WebM → PCM)
|
# ffmpeg: audio conversion for AWS Transcribe (WebM → PCM)
|
||||||
# curl: HTTP helper used by the OpenBao entrypoint and health/debug tooling
|
# curl: HTTP helper used by the OpenBao entrypoint and health/debug tooling
|
||||||
# jq: JSON parsing for the entrypoint's OpenBao secret-fetch step
|
# 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
|
# Pull the bao CLI out of the upstream image — matches host arch because
|
||||||
# buildx pulls the right manifest-list variant per build.
|
# buildx pulls the right manifest-list variant per build.
|
||||||
|
|
|
||||||
BIN
assets/learning/slides-reference.pptx
Normal file
BIN
assets/learning/slides-reference.pptx
Normal file
Binary file not shown.
47
package-lock.json
generated
47
package-lock.json
generated
|
|
@ -26,7 +26,6 @@
|
||||||
"express": "^4.21.0",
|
"express": "^4.21.0",
|
||||||
"express-rate-limit": "^7.4.0",
|
"express-rate-limit": "^7.4.0",
|
||||||
"helmet": "^8.0.0",
|
"helmet": "^8.0.0",
|
||||||
"image-size": "^2.0.2",
|
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"katex": "^0.18.7",
|
"katex": "^0.18.7",
|
||||||
"mammoth": "^1.8.0",
|
"mammoth": "^1.8.0",
|
||||||
|
|
@ -39,7 +38,6 @@
|
||||||
"openid-client": "^6.8.2",
|
"openid-client": "^6.8.2",
|
||||||
"pdf-parse": "^1.1.1",
|
"pdf-parse": "^1.1.1",
|
||||||
"pg": "^8.13.0",
|
"pg": "^8.13.0",
|
||||||
"pptxgenjs": "^4.0.1",
|
|
||||||
"prom-client": "^15.1.3",
|
"prom-client": "^15.1.3",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"redis": "^4.7.1",
|
"redis": "^4.7.1",
|
||||||
|
|
@ -5056,12 +5054,6 @@
|
||||||
"url": "https://opencollective.com/express"
|
"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": {
|
"node_modules/https-proxy-agent": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||||
|
|
@ -5122,18 +5114,6 @@
|
||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/immediate": {
|
||||||
"version": "3.0.6",
|
"version": "3.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||||
|
|
@ -6481,33 +6461,6 @@
|
||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/process-nextick-args": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,6 @@
|
||||||
"express": "^4.21.0",
|
"express": "^4.21.0",
|
||||||
"express-rate-limit": "^7.4.0",
|
"express-rate-limit": "^7.4.0",
|
||||||
"helmet": "^8.0.0",
|
"helmet": "^8.0.0",
|
||||||
"image-size": "^2.0.2",
|
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"katex": "^0.18.7",
|
"katex": "^0.18.7",
|
||||||
"mammoth": "^1.8.0",
|
"mammoth": "^1.8.0",
|
||||||
|
|
@ -47,7 +46,6 @@
|
||||||
"openid-client": "^6.8.2",
|
"openid-client": "^6.8.2",
|
||||||
"pdf-parse": "^1.1.1",
|
"pdf-parse": "^1.1.1",
|
||||||
"pg": "^8.13.0",
|
"pg": "^8.13.0",
|
||||||
"pptxgenjs": "^4.0.1",
|
|
||||||
"prom-client": "^15.1.3",
|
"prom-client": "^15.1.3",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"redis": "^4.7.1",
|
"redis": "^4.7.1",
|
||||||
|
|
@ -77,8 +75,7 @@
|
||||||
},
|
},
|
||||||
"speech-rule-engine": {
|
"speech-rule-engine": {
|
||||||
"@xmldom/xmldom": "0.9.12"
|
"@xmldom/xmldom": "0.9.12"
|
||||||
},
|
}
|
||||||
"image-size": "^2.0.2"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "24.x"
|
"node": "24.x"
|
||||||
|
|
|
||||||
|
|
@ -551,314 +551,60 @@ router.post('/webdav-path', require('../utils/policy').requireFeature('nextcloud
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── POST /api/admin/learning/generate-pptx ──────────────────
|
// ── POST /api/admin/learning/generate-pptx ──────────────────
|
||||||
// Fit an image inside a box without distorting it.
|
// pandoc measures images itself and emits an extent with the right shape, so
|
||||||
//
|
// nothing here needs to size them.
|
||||||
// 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)
|
|
||||||
|
|
||||||
router.post('/generate-pptx', async function(req, res) {
|
router.post('/generate-pptx', async function(req, res) {
|
||||||
|
var workdir = null;
|
||||||
try {
|
try {
|
||||||
var { markdown, title } = req.body;
|
var { markdown, title } = req.body;
|
||||||
if (!markdown) return res.status(400).json({ error: 'markdown required' });
|
if (!markdown) return res.status(400).json({ error: 'markdown required' });
|
||||||
|
|
||||||
var assetImages = {};
|
var fsp = require('fs/promises');
|
||||||
// Kept alongside the data URI so the image can be measured. pptxgenjs cannot
|
var os = require('os');
|
||||||
// do it: its sizing option emits the box dimensions verbatim with
|
var pathMod = require('path');
|
||||||
// <a:stretch/>, so a portrait image handed to a landscape box comes out
|
var { execFile } = require('child_process');
|
||||||
// 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();
|
|
||||||
|
|
||||||
pptx.layout = 'LAYOUT_WIDE'; // 16:9
|
workdir = await fsp.mkdtemp(pathMod.join(os.tmpdir(), 'pptx-'));
|
||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────
|
// Only images this user owns, written beside the markdown under names we
|
||||||
|
// choose. Everything else is stripped: pandoc resolves an image link
|
||||||
// Parse inline markdown (**bold**, *italic*, `code`, ***both***) into pptxgenjs text objects
|
// against the filesystem, so a link naming any local path would embed that
|
||||||
function parseInline(text, defaults) {
|
// file into the deck. The allow-list is the set we just fetched by id.
|
||||||
var parts = [];
|
var allowed = Object.create(null);
|
||||||
var re = /(\*\*\*(.+?)\*\*\*|\*\*(.+?)\*\*|\*(.+?)\*|`(.+?)`)/g;
|
var refs = require('../utils/generatedImageLinks').references(markdown);
|
||||||
var last = 0;
|
for (var r = 0; r < refs.length; r++) {
|
||||||
var m;
|
var id = refs[r];
|
||||||
while ((m = re.exec(text)) !== null) {
|
var image = await require('../utils/generatedImages').service().asset(id, req.user);
|
||||||
if (m.index > last) parts.push({ text: text.slice(last, m.index), options: Object.assign({}, defaults) });
|
var ext = String(image.mime || '').indexOf('png') !== -1 ? 'png' : 'jpg';
|
||||||
if (m[2]) parts.push({ text: m[2], options: Object.assign({}, defaults, { bold: true, italic: true }) });
|
var file = 'img-' + r + '.' + ext;
|
||||||
else if (m[3]) parts.push({ text: m[3], options: Object.assign({}, defaults, { bold: true }) });
|
await fsp.writeFile(pathMod.join(workdir, file), image.bytes);
|
||||||
else if (m[4]) parts.push({ text: m[4], options: Object.assign({}, defaults, { italic: true }) });
|
allowed['/api/generated-images/' + id] = file;
|
||||||
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) }];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse a markdown table block into { headers: string[], rows: string[][] }
|
var source = String(markdown).replace(/!\[([^\]]*)\]\(([^)]+)\)/g, function (whole, alt, src) {
|
||||||
function parseTable(lines) {
|
var local = allowed[String(src).trim()];
|
||||||
var headers = lines[0].split('|').map(function(c) { return c.trim(); }).filter(Boolean);
|
// A link we did not resolve is dropped rather than passed through, so a
|
||||||
var rows = [];
|
// deck can never be made to read a path off this host.
|
||||||
for (var i = 2; i < lines.length; i++) {
|
return local ? '' : '';
|
||||||
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' });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Write to buffer and send
|
await fsp.writeFile(pathMod.join(workdir, 'deck.md'), source, 'utf8');
|
||||||
var pptxBuffer = await pptx.write({ outputType: 'nodebuffer' });
|
|
||||||
|
// 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();
|
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');
|
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.presentationml.presentation');
|
||||||
|
|
@ -868,6 +614,11 @@ router.post('/generate-pptx', async function(req, res) {
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[PPTX]', err.message);
|
console.error('[PPTX]', err.message);
|
||||||
res.status(500).json({ error: 'Request failed' });
|
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); }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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\);/);
|
assert.match(route, /tracker\.store\(req\.user\.id, question, result, sources\);/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('PPTX images keep their shape', () => {
|
test('slides are built by pandoc, from markdown, with a reference template', () => {
|
||||||
// PowerPoint scales an image to whatever extent it is given. pptxgenjs offers
|
// pptxgenjs is gone. It stretched every image — reading the slide XML it
|
||||||
// a `sizing` option that looks like it handles this, but it emits the box
|
// emitted showed the target box verbatim with <a:stretch/>, so a 200x800
|
||||||
// dimensions unchanged with <a:stretch/> — verified by reading the slide XML:
|
// image in an 11.8x3.9 box came out 1:4 squashed to 3:1 — and it could not
|
||||||
// a 200x800 image in an 11.8x3.9 box came out as cx=10789920 cy=3566160,
|
// do better, because it never measures an image; its own getSizeFromImage is
|
||||||
// i.e. stretched from 1:4 to 3:1. It cannot do better, because it never
|
// commented out as unused. pandoc measures them: rendered and inspected, a
|
||||||
// measures the image; its own getSizeFromImage is commented out as unused.
|
// 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');
|
const src = read('src/routes/learningAI.js');
|
||||||
assert.match(src, /function fitImage\(bytes, x, y, maxW, maxH\)/);
|
assert.doesNotMatch(src, /pptxgenjs|PptxGenJS/, 'nothing builds slides by hand any more');
|
||||||
assert.match(src, /var dims = measurer\(\)\(bytes\);/, 'measured here instead');
|
assert.match(src, /execFile\('pandoc', \['deck\.md', '--reference-doc=' \+ reference, '-o', 'deck\.pptx'\]/);
|
||||||
assert.match(src, /var scale = Math\.min\(maxW \/ dims\.width, maxH \/ dims\.height\);/,
|
assert.match(src, /timeout: 60000/, 'a conversion cannot hang the request');
|
||||||
'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');
|
// Design lives in the template, so restyling is editing a file in PowerPoint.
|
||||||
// An image that cannot be measured must not fail the export.
|
assert.match(src, /assets', 'learning', 'slides-reference\.pptx'/);
|
||||||
assert.match(src, /var fallback = \{ x: x, y: y, w: maxW, h: maxH \};/);
|
assert.ok(require('node:fs').existsSync(
|
||||||
assert.match(src, /catch \(e\) \{\s*\n\s*return fallback;/);
|
require('node:path').join(__dirname, '..', 'assets', 'learning', 'slides-reference.pptx')),
|
||||||
// The sizing option is gone: leaving it would only re-stretch what fitImage
|
'and that template ships with the app');
|
||||||
// just measured.
|
|
||||||
const placement = src.slice(src.indexOf("if (type === 'image')"), src.indexOf("// ── Code block ──"));
|
// pandoc must be in the image, or every export fails at runtime.
|
||||||
assert.doesNotMatch(placement, /sizing:/, 'no sizing option fighting the measured box');
|
assert.match(read('Dockerfile'), /apk add --no-cache ffmpeg curl jq pandoc-cli/);
|
||||||
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'));
|
const pkg = JSON.parse(read('package.json'));
|
||||||
assert.ok(pkg.dependencies['image-size'], 'declared directly rather than borrowed transitively');
|
assert.ok(!pkg.dependencies['pptxgenjs'], 'and the library is no longer a dependency');
|
||||||
assert.equal(pkg.overrides['image-size'], '^2.0.2',
|
assert.ok(!pkg.dependencies['image-size'], 'nor the parser it dragged in');
|
||||||
'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', () => {
|
test('a deck can only embed images the requester owns', () => {
|
||||||
// ICNS, JXL and HEIF have open denial-of-service advisories against EVERY
|
// pandoc resolves an image link against the filesystem, so a markdown link
|
||||||
// published image-size — GHSA-w3rx-r6r6-pgpr and GHSA-5p2g-fcmc-qvqq, both
|
// naming any local path would read that file into the deck. Only the images
|
||||||
// ranged <=2.0.2. There is no fixed release, so the fix is not to run them.
|
// 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');
|
const src = read('src/routes/learningAI.js');
|
||||||
assert.match(src, /var keep = \{ png: 1, jpg: 1, webp: 1, gif: 1 \};/);
|
assert.match(src, /var image = await require\('\.\.\/utils\/generatedImages'\)\.service\(\)\.asset\(id, req\.user\);/,
|
||||||
assert.match(src, /lib\.disableTypes\(\(lib\.types \|\| \[\]\)\.filter\(function \(t\) \{ return !keep\[t\]; \}\)\)/);
|
'ownership is checked when fetching');
|
||||||
// disableTypes replaces the enabled set, so calling it per measurement would
|
assert.match(src, /return local \? '!\[' \+ alt \+ '\]\(' \+ local \+ '\)' : '';/,
|
||||||
// be wasted work and easy to get wrong.
|
'an unresolved link is removed, not passed to pandoc');
|
||||||
assert.match(src, /if \(!imageSizeReady\) \{/, 'done once, at first use');
|
// The working directory is per request and always cleaned up.
|
||||||
|
assert.match(src, /workdir = await fsp\.mkdtemp\(/);
|
||||||
// And the library actually honours it — this is the contract that could move.
|
assert.match(src, /\} finally \{[\s\S]{0,200}rm\(workdir, \{ recursive: true, force: true \}\)/);
|
||||||
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');
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -154,7 +154,7 @@ test('actual authenticated asset/settings and Learning content write routes enfo
|
||||||
const learningAI=load('src/routes/learningAI.js',{
|
const learningAI=load('src/routes/learningAI.js',{
|
||||||
express,multer:require('multer'),axios:{},path:require('path'),'../utils/ai':{},'../utils/imageTool':require('../src/utils/imageTool'),
|
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'),
|
'../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)');
|
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', {
|
const clinicalRoutes = load('src/routes/clinicalAssistant.js', {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue