diff --git a/Dockerfile b/Dockerfile
index 25149fc1..b53f972f 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -21,6 +21,20 @@ WORKDIR /app
# , so every image in every generated deck was distorted.
RUN apk add --no-cache ffmpeg curl jq pandoc-cli
+# python-pptx builds the slide decks. pandoc still writes Word, where its output
+# is good, but its pptx writer can only map markdown onto a handful of reference
+# layouts: no per-slide layout, no positioning, no control over where an image
+# lands or how large it is. That ceiling is the renderer's, not the model's — a
+# better-written deck still came out as bullets on a template, and slides
+# overflowed until autofit was injected into the emitted OOXML by hand.
+#
+# py3-lxml and py3-pillow come from apk rather than pip because both are C
+# extensions and Alpine has no wheels for them; installing from source here
+# would mean carrying a compiler in the runtime image. Adds ~58MB.
+RUN apk add --no-cache python3 py3-pip py3-lxml py3-pillow \
+ && pip install --break-system-packages --no-cache-dir python-pptx==1.0.2 \
+ && python3 -c 'import pptx'
+
# Pull the bao CLI out of the upstream image — matches host arch because
# buildx pulls the right manifest-list variant per build.
COPY --from=bao-src /bin/bao /usr/local/bin/bao
diff --git a/migrations/1780500000000_resource-images.js b/migrations/1780500000000_resource-images.js
new file mode 100644
index 00000000..f52ea84f
--- /dev/null
+++ b/migrations/1780500000000_resource-images.js
@@ -0,0 +1,18 @@
+// Which figures belong to which resource.
+//
+// The illustrations were queued as image jobs and shown on screen, but nothing
+// recorded that they belonged to the resource — so an exported deck had no way
+// to include them, and the pictures a person asked for lived only in the page
+// they were generated on. This is that missing link.
+//
+// Job ids rather than a join table: they are opaque uuids owned by the same
+// user, the ordering is the order the model asked for them, and there is no
+// second thing that needs to query them.
+
+exports.up = pgm => pgm.sql(`
+ ALTER TABLE user_resources ADD COLUMN IF NOT EXISTS image_ids JSONB NOT NULL DEFAULT '[]'::jsonb;
+`);
+
+exports.down = pgm => pgm.sql(`
+ ALTER TABLE user_resources DROP COLUMN IF EXISTS image_ids;
+`);
diff --git a/scripts/render_pptx.py b/scripts/render_pptx.py
new file mode 100644
index 00000000..964c9066
--- /dev/null
+++ b/scripts/render_pptx.py
@@ -0,0 +1,365 @@
+#!/usr/bin/env python3
+"""Render a slide deck from a JSON spec on stdin to a .pptx file.
+
+Replaces pandoc's pptx writer, which could only map markdown onto a handful of
+reference layouts. Everything that made those decks look generated is decided
+here instead: which layout a slide gets, how large its text is, where an image
+sits and at what aspect ratio, how a table is drawn.
+
+Input (stdin, JSON):
+ {"title": str, "subtitle": str, "date": str,
+ "slides": [ {...} ],
+ "images": [ "/abs/path.png", ... ] }
+
+Each slide is one of:
+ {"type": "title", "heading": str, "subtitle": str}
+ {"type": "section", "heading": str}
+ {"type": "bullets", "heading": str, "bullets": [{"text":str,"level":int}], "notes": str}
+ {"type": "two", "heading": str, "left": [...], "right": [...]}
+ {"type": "table", "heading": str, "header": [str], "rows": [[str]]}
+ {"type": "image", "heading": str, "image": str, "caption": str}
+
+Output: argv[1], a .pptx path. Errors go to stderr and exit non-zero, so the
+caller can fall back rather than ship a broken file.
+"""
+import json
+import sys
+import os
+
+from pptx import Presentation
+from pptx.util import Emu, Pt
+from pptx.dml.color import RGBColor
+from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
+
+try:
+ from PIL import Image
+except Exception: # pragma: no cover - Pillow is installed alongside
+ Image = None
+
+# 16:9. Pandoc's default reference doc is 4:3, which is why those decks looked
+# dated before anything else about them did.
+SLIDE_W = Emu(12192000)
+SLIDE_H = Emu(6858000)
+
+MARGIN = Emu(685800) # 0.75"
+HEADING_TOP = Emu(457200) # 0.5"
+HEADING_H = Emu(1005840) # 1.1"
+BODY_TOP = HEADING_TOP + HEADING_H + Emu(228600) # clears the accent rule
+BODY_H = SLIDE_H - BODY_TOP - MARGIN
+BODY_W = SLIDE_W - MARGIN * 2
+
+INK = RGBColor(0x1F, 0x29, 0x37)
+MUTED = RGBColor(0x4B, 0x55, 0x63)
+ACCENT = RGBColor(0x25, 0x63, 0xEB)
+RULE = RGBColor(0xE5, 0xE7, 0xEB)
+PAPER = RGBColor(0xFF, 0xFF, 0xFF)
+
+FONT = "Calibri"
+
+# Text is sized to fit rather than left to the renderer's autofit, which only
+# some viewers honour and which LibreOffice ignores entirely when converting to
+# PDF — the reason slides were being cut off mid-sentence.
+BULLET_SIZES = [26, 24, 22, 20, 18, 16, 14]
+CHARS_PER_LINE_AT_24PT = 62.0
+
+
+def _blank(prs):
+ return prs.slides.add_slide(prs.slide_layouts[6])
+
+
+def _textbox(slide, left, top, width, height):
+ box = slide.shapes.add_textbox(left, top, width, height)
+ frame = box.text_frame
+ frame.word_wrap = True
+ frame.margin_left = 0
+ frame.margin_right = 0
+ frame.margin_top = 0
+ frame.margin_bottom = 0
+ return frame
+
+
+def _run(paragraph, text, size, bold=False, color=INK, italic=False):
+ run = paragraph.add_run()
+ run.text = text
+ run.font.size = Pt(size)
+ run.font.bold = bold
+ run.font.italic = italic
+ run.font.name = FONT
+ run.font.color.rgb = color
+ return run
+
+
+def _heading(slide, text, accent_rule=True):
+ frame = _textbox(slide, MARGIN, HEADING_TOP, BODY_W, HEADING_H)
+ frame.vertical_anchor = MSO_ANCHOR.BOTTOM
+ para = frame.paragraphs[0]
+ # Long headings shrink rather than wrapping into the body.
+ size = 34 if len(text) <= 52 else (30 if len(text) <= 74 else 26)
+ _run(para, text, size, bold=True)
+ if accent_rule:
+ bar = slide.shapes.add_shape(1, MARGIN, HEADING_TOP + HEADING_H + Emu(45720), Emu(548640), Emu(45720))
+ bar.fill.solid()
+ bar.fill.fore_color.rgb = ACCENT
+ bar.line.fill.background()
+ bar.shadow.inherit = False
+
+
+def _estimate_lines(text, size, width_frac=1.0):
+ """How many wrapped lines this run of text will take at this size."""
+ if not text:
+ return 1
+ per_line = max(12.0, CHARS_PER_LINE_AT_24PT * (24.0 / size) * width_frac)
+ return max(1, int(len(text) / per_line) + (1 if len(text) % per_line else 0))
+
+
+def _fit_size(items, width_frac=1.0, available_h_emu=None):
+ """Largest size from BULLET_SIZES at which every bullet fits the body box."""
+ available = available_h_emu if available_h_emu is not None else int(BODY_H)
+ for size in BULLET_SIZES:
+ line_emu = Pt(size * 1.35).emu
+ gap_emu = Pt(size * 0.55).emu
+ total = 0
+ for item in items:
+ total += _estimate_lines(item.get("text", ""), size, width_frac) * line_emu + gap_emu
+ if total <= available:
+ return size
+ return BULLET_SIZES[-1]
+
+
+def _hang(para, size, level):
+ """Wrapped lines align under the text, not back at the margin.
+
+ python-pptx exposes no indent API, so this writes marL/indent onto a:pPr
+ directly. Without it every bullet that wrapped ran back to the left edge,
+ which is the clearest single tell that a deck was generated rather than
+ made."""
+ indent = int(Pt(size * 0.95).emu)
+ left = indent * (level + 1)
+ pPr = para._pPr if para._pPr is not None else para._p.get_or_add_pPr()
+ pPr.set("marL", str(left))
+ pPr.set("indent", str(-indent))
+
+
+def _bullets(frame, items, size, width_frac=1.0):
+ first = True
+ for item in items:
+ text = (item.get("text") or "").strip()
+ if not text:
+ continue
+ para = frame.paragraphs[0] if first else frame.add_paragraph()
+ first = False
+ level = min(int(item.get("level") or 0), 4)
+ para.level = level
+ para.space_after = Pt(size * 0.55)
+ _hang(para, size, level)
+ marker = "• " if level == 0 else "– "
+ _run(para, marker, size, color=ACCENT if level == 0 else MUTED)
+ # Inline **bold** is kept, because emphasis is most of what a bullet has.
+ for chunk, bold in _split_bold(text):
+ _run(para, chunk, size, bold=bold, color=INK if level == 0 else MUTED)
+
+
+def _split_bold(text):
+ out = []
+ rest = text
+ while "**" in rest:
+ before, _, after = rest.partition("**")
+ if before:
+ out.append((before, False))
+ bold, sep, remainder = after.partition("**")
+ if not sep:
+ out.append(("**" + bold, False))
+ return out
+ out.append((bold, True))
+ rest = remainder
+ if rest:
+ out.append((rest, False))
+ return out or [(text, False)]
+
+
+def _notes(slide, text):
+ if not text:
+ return
+ slide.notes_slide.notes_text_frame.text = text
+
+
+def slide_title(prs, spec):
+ slide = _blank(prs)
+ frame = _textbox(slide, MARGIN, Emu(2057400), BODY_W, Emu(1828800))
+ frame.vertical_anchor = MSO_ANCHOR.MIDDLE
+ para = frame.paragraphs[0]
+ heading = spec.get("heading") or "Untitled"
+ _run(para, heading, 44 if len(heading) <= 60 else 36, bold=True)
+ for line in [spec.get("subtitle"), spec.get("date")]:
+ if not line:
+ continue
+ sub = frame.add_paragraph()
+ sub.space_before = Pt(10)
+ _run(sub, line, 18, color=MUTED)
+ bar = slide.shapes.add_shape(1, MARGIN, Emu(1874520), Emu(1097280), Emu(54864))
+ bar.fill.solid()
+ bar.fill.fore_color.rgb = ACCENT
+ bar.line.fill.background()
+ bar.shadow.inherit = False
+ return slide
+
+
+def slide_section(prs, spec):
+ slide = _blank(prs)
+ frame = _textbox(slide, MARGIN, Emu(2743200), BODY_W, Emu(1371600))
+ frame.vertical_anchor = MSO_ANCHOR.MIDDLE
+ para = frame.paragraphs[0]
+ _run(para, spec.get("heading") or "", 36, bold=True, color=ACCENT)
+ return slide
+
+
+def slide_bullets(prs, spec):
+ slide = _blank(prs)
+ _heading(slide, spec.get("heading") or "")
+ items = spec.get("bullets") or []
+ size = _fit_size(items)
+ frame = _textbox(slide, MARGIN, BODY_TOP, BODY_W, BODY_H)
+ _bullets(frame, items, size)
+ _notes(slide, spec.get("notes"))
+ return slide
+
+
+def slide_two(prs, spec):
+ slide = _blank(prs)
+ _heading(slide, spec.get("heading") or "")
+ gutter = Emu(457200)
+ col_w = Emu(int((BODY_W - gutter) / 2))
+ left_items = spec.get("left") or []
+ right_items = spec.get("right") or []
+ size = min(_fit_size(left_items, 0.46), _fit_size(right_items, 0.46))
+ for index, items in enumerate((left_items, right_items)):
+ if not items:
+ continue
+ left = MARGIN + (col_w + gutter) * index
+ frame = _textbox(slide, left, BODY_TOP, col_w, BODY_H)
+ _bullets(frame, items, size, width_frac=0.46)
+ _notes(slide, spec.get("notes"))
+ return slide
+
+
+def slide_table(prs, spec):
+ slide = _blank(prs)
+ _heading(slide, spec.get("heading") or "")
+ header = spec.get("header") or []
+ rows = spec.get("rows") or []
+ if not header and not rows:
+ return slide
+ cols = max(len(header), max((len(r) for r in rows), default=1))
+ body_rows = len(rows) + (1 if header else 0)
+ height = min(int(BODY_H), Emu(int(365760 * body_rows)))
+ shape = slide.shapes.add_table(body_rows, cols, MARGIN, BODY_TOP, BODY_W, height)
+ table = shape.table
+ # Sized to the row count: eleven rows at one size is unreadable, four is airy.
+ size = 16 if body_rows <= 6 else (13 if body_rows <= 9 else 11)
+
+ def write(cell, text, bold, color):
+ cell.text = ""
+ cell.margin_left = Emu(91440)
+ cell.margin_right = Emu(91440)
+ cell.margin_top = Emu(45720)
+ cell.margin_bottom = Emu(45720)
+ cell.vertical_anchor = MSO_ANCHOR.MIDDLE
+ para = cell.text_frame.paragraphs[0]
+ for chunk, is_bold in _split_bold(text or ""):
+ _run(para, chunk, size, bold=bold or is_bold, color=color)
+
+ offset = 0
+ if header:
+ for c in range(cols):
+ cell = table.cell(0, c)
+ write(cell, header[c] if c < len(header) else "", True, PAPER)
+ cell.fill.solid()
+ cell.fill.fore_color.rgb = ACCENT
+ offset = 1
+ for r, row in enumerate(rows):
+ for c in range(cols):
+ cell = table.cell(r + offset, c)
+ write(cell, row[c] if c < len(row) else "", False, INK)
+ cell.fill.solid()
+ cell.fill.fore_color.rgb = PAPER if r % 2 == 0 else RGBColor(0xF9, 0xFA, 0xFB)
+ _notes(slide, spec.get("notes"))
+ return slide
+
+
+def slide_image(prs, spec):
+ """A figure sized to its own aspect ratio, never stretched to a box."""
+ slide = _blank(prs)
+ heading = spec.get("heading") or ""
+ if heading:
+ _heading(slide, heading)
+ top, avail_h = BODY_TOP, BODY_H
+ else:
+ top, avail_h = MARGIN, SLIDE_H - MARGIN * 2
+
+ path = spec.get("image")
+ caption = (spec.get("caption") or "").strip()
+ caption_h = Emu(365760) if caption else Emu(0)
+ avail_h = Emu(int(avail_h - caption_h))
+
+ ratio = 1.0
+ if Image is not None and path and os.path.exists(path):
+ try:
+ with Image.open(path) as img:
+ if img.height:
+ ratio = img.width / float(img.height)
+ except Exception:
+ ratio = 1.0
+
+ # Fit inside the box, preserving aspect, then centre it.
+ width = int(BODY_W)
+ height = int(width / ratio) if ratio else int(avail_h)
+ if height > int(avail_h):
+ height = int(avail_h)
+ width = int(height * ratio)
+ left = Emu(int((SLIDE_W - width) / 2))
+ if path and os.path.exists(path):
+ slide.shapes.add_picture(path, left, Emu(int(top)), Emu(width), Emu(height))
+
+ if caption:
+ frame = _textbox(slide, MARGIN, Emu(int(top) + height + 91440), BODY_W, caption_h)
+ para = frame.paragraphs[0]
+ para.alignment = PP_ALIGN.CENTER
+ _run(para, caption, 14, color=MUTED, italic=True)
+ _notes(slide, spec.get("notes"))
+ return slide
+
+
+BUILDERS = {
+ "title": slide_title,
+ "section": slide_section,
+ "bullets": slide_bullets,
+ "two": slide_two,
+ "table": slide_table,
+ "image": slide_image,
+}
+
+
+def main():
+ if len(sys.argv) < 2:
+ print("usage: render_pptx.py (spec on stdin)", file=sys.stderr)
+ return 2
+ spec = json.load(sys.stdin)
+
+ prs = Presentation()
+ prs.slide_width = SLIDE_W
+ prs.slide_height = SLIDE_H
+
+ slides = spec.get("slides") or []
+ if not slides:
+ print("spec contains no slides", file=sys.stderr)
+ return 3
+
+ for item in slides:
+ BUILDERS.get(item.get("type") or "bullets", slide_bullets)(prs, item)
+
+ prs.save(sys.argv[1])
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/src/routes/myResources.js b/src/routes/myResources.js
index 5cba3487..136215aa 100644
--- a/src/routes/myResources.js
+++ b/src/routes/myResources.js
@@ -301,10 +301,14 @@ router.post('/my-resources/generate', async function (req, res) {
var markdown = String((ai && ai.content) || '').trim();
if (!markdown) return res.status(502).json({ error: 'The model returned nothing. Try again.' });
+ // The figures belong to the resource, or an exported deck has no way to
+ // include the pictures the author asked for.
+ var figureIds = (ai.imageJobs || []).map(function (job) { return job.jobId; }).filter(Boolean);
var row = await db.get(
- 'INSERT INTO user_resources (user_id, title, kind, markdown, topic, grounded_count) ' +
- 'VALUES (?, ?, ?, ?, ?, ?) RETURNING id, title, kind, topic, grounded_count, created_at',
- [req.user.id, firstHeading(markdown, topic), kind, markdown, topic.slice(0, 500), corpus.sources.length]
+ 'INSERT INTO user_resources (user_id, title, kind, markdown, topic, grounded_count, image_ids) ' +
+ 'VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id, title, kind, topic, grounded_count, created_at',
+ [req.user.id, firstHeading(markdown, topic), kind, markdown, topic.slice(0, 500),
+ corpus.sources.length, JSON.stringify(figureIds)]
);
res.json({
@@ -439,10 +443,14 @@ router.post('/my-resources/:id/refine', async function (req, res) {
var revised = String((ai && ai.content) || '').trim();
if (!revised) return res.status(502).json({ error: 'The model returned nothing. Try again.' });
+ // Figures from a modification are added to the ones already there, not
+ // swapped for them: "add two more diagrams" means more, not instead.
+ var added = (ai.imageJobs || []).map(function (job) { return job.jobId; }).filter(Boolean);
var row = await db.get(
- 'UPDATE user_resources SET markdown = ?, title = ?, updated_at = NOW() ' +
+ 'UPDATE user_resources SET markdown = ?, title = ?, updated_at = NOW(), ' +
+ 'image_ids = image_ids || ?::jsonb ' +
'WHERE id = ? AND user_id = ? RETURNING id, title, updated_at',
- [revised, firstHeading(revised), existing.id, req.user.id]
+ [revised, firstHeading(revised), JSON.stringify(added), existing.id, req.user.id]
);
res.json({
success: true, resource: row, markdown: revised,
@@ -459,6 +467,30 @@ router.post('/my-resources/:id/refine', async function (req, res) {
}
});
+// Fetches a resource's finished figures onto disk in the order they were made.
+// asset() already scopes to the owner, so this cannot reach anyone else's.
+async function collectFigures(ids, user, dir) {
+ var list = [];
+ try { list = Array.isArray(ids) ? ids : JSON.parse(ids || '[]'); } catch (e) { return []; }
+ var fsp = require('fs/promises');
+ var pathMod = require('path');
+ var service = require('../utils/generatedImages');
+ var out = [];
+ for (var i = 0; i < list.length && out.length < 12; i++) {
+ try {
+ var asset = await service.service().asset(String(list[i]), user);
+ var ext = ({ 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp' })[asset.mime] || 'png';
+ var file = pathMod.join(dir, 'figure-' + i + '.' + ext);
+ await fsp.writeFile(file, asset.bytes);
+ out.push(file);
+ } catch (e) {
+ // Still generating, failed, or deleted. The deck is fine without it.
+ console.warn('[my-resources] figure unavailable for export:', e.message);
+ }
+ }
+ return out;
+}
+
// ── Export ──────────────────────────────────────────────────
router.get('/my-resources/:id/export', async function (req, res) {
try {
@@ -466,7 +498,7 @@ router.get('/my-resources/:id/export', async function (req, res) {
if (!documentExport.isSupported(format)) return res.status(400).json({ error: 'Unsupported format' });
var row = await db.get(
- 'SELECT title, kind, markdown FROM user_resources WHERE id = ? AND user_id = ?',
+ 'SELECT title, kind, markdown, image_ids FROM user_resources WHERE id = ? AND user_id = ?',
[parseInt(req.params.id, 10), req.user.id]
);
if (!row) return res.status(404).json({ error: 'Not found' });
@@ -477,7 +509,24 @@ router.get('/my-resources/:id/export', async function (req, res) {
return res.status(400).json({ error: 'An article has no slides. Download it as Word or PDF.' });
}
- var bytes = await documentExport.render(row.markdown, row.kind, format);
+ // Figures are written to a scratch directory for the renderer and removed
+ // afterwards. A figure that cannot be fetched is left out rather than
+ // failing a download that works without it.
+ var figures = [];
+ var scratch = null;
+ if (format !== 'docx') {
+ scratch = await require('fs/promises').mkdtemp(require('path').join(require('os').tmpdir(), 'figs-'));
+ figures = await collectFigures(row.image_ids, req.user, scratch);
+ }
+ var bytes;
+ try {
+ bytes = await documentExport.render(row.markdown, row.kind, format, { images: figures });
+ } finally {
+ if (scratch) {
+ await require('fs/promises').rm(scratch, { recursive: true, force: true })
+ .catch(function (e) { console.warn('[my-resources] scratch cleanup:', e.message); });
+ }
+ }
res.setHeader('Content-Type', documentExport.mimeFor(format));
res.setHeader('Content-Disposition',
'attachment; filename="' + documentExport.filename(row.title, format) + '"');
diff --git a/src/utils/documentExport.js b/src/utils/documentExport.js
index 2ac50f4d..2dc02f79 100644
--- a/src/utils/documentExport.js
+++ b/src/utils/documentExport.js
@@ -15,10 +15,12 @@
var fsp = require('fs/promises');
var os = require('os');
var pathMod = require('path');
-var { execFile } = require('child_process');
+var { execFile, spawn } = require('child_process');
var JSZip = require('jszip');
+var slideSpec = require('./slideSpec');
var REFERENCE_DECK = pathMod.join(__dirname, '..', '..', 'assets', 'learning', 'slides-reference.pptx');
+var DECK_RENDERER = pathMod.join(__dirname, '..', '..', 'scripts', 'render_pptx.py');
var GOTENBERG = process.env.GOTENBERG_URL || 'http://gotenberg:3000';
var FORMATS = {
@@ -87,33 +89,28 @@ function runPandoc(args, cwd) {
* Returns a Buffer. Throws with a readable message; the caller decides whether
* a failed PDF is fatal.
*/
-async function render(markdown, kind, format) {
+async function render(markdown, kind, format, options) {
if (!isSupported(format)) throw new Error('Unsupported format: ' + format);
+ options = options || {};
var workdir = await fsp.mkdtemp(pathMod.join(os.tmpdir(), 'export-'));
try {
await fsp.writeFile(pathMod.join(workdir, 'doc.md'), String(markdown || ''), 'utf8');
var office = kind === 'presentation' ? 'pptx' : 'docx';
- var officeArgs = ['doc.md', '-o', 'doc.' + office];
- if (office === 'pptx') officeArgs.splice(1, 0, '--reference-doc=' + REFERENCE_DECK);
- await runPandoc(officeArgs, workdir);
+ if (office === 'docx' || format === 'docx') {
+ await runPandoc(['doc.md', '-o', 'doc.docx'], workdir);
+ }
+ if (office === 'pptx' || format === 'pptx') {
+ await buildDeck(markdown, workdir, options.images || []);
+ }
if (format !== 'pdf') {
- if (format !== office) {
- // Asking for docx of a presentation, or pptx of an article. Render the
- // other one rather than refusing: the markdown supports both.
- var otherArgs = ['doc.md', '-o', 'doc.' + format];
- if (format === 'pptx') otherArgs.splice(1, 0, '--reference-doc=' + REFERENCE_DECK);
- await runPandoc(otherArgs, workdir);
- }
- var built = await fsp.readFile(pathMod.join(workdir, 'doc.' + format));
- return format === 'pptx' ? await fitSlideText(built) : built;
+ return await fsp.readFile(pathMod.join(workdir, 'doc.' + format));
}
// PDF: hand the office file to Gotenberg. Its LibreOffice keeps the deck's
// layout, which is why this is not rendered from the markdown directly.
var bytes = await fsp.readFile(pathMod.join(workdir, 'doc.' + office));
- if (office === 'pptx') bytes = await fitSlideText(bytes);
var form = new FormData();
form.append('files', new File([bytes], 'doc.' + office, { type: FORMATS[office].mime }));
var response = await fetch(GOTENBERG + '/forms/libreoffice/convert', {
@@ -127,6 +124,42 @@ async function render(markdown, kind, format) {
}
}
+// ── Deck ────────────────────────────────────────────────────
+// Built by scripts/render_pptx.py rather than pandoc. Pandoc's pptx writer maps
+// markdown onto a handful of reference layouts and gives no control over
+// per-slide layout, positioning, or how large an image is drawn — the reason
+// every generated deck came out as bullets on a template, and the reason
+// autofit had to be injected into its output by hand afterwards.
+//
+// If the renderer fails for any reason, pandoc still produces a deck. A plainer
+// deck beats a failed download.
+async function buildDeck(markdown, workdir, images) {
+ var out = pathMod.join(workdir, 'doc.pptx');
+ try {
+ var spec = slideSpec.build(markdown, { images: images });
+ await new Promise(function (resolve, reject) {
+ var child = spawn('python3', [DECK_RENDERER, out], { cwd: workdir });
+ var stderr = '';
+ child.stderr.on('data', function (chunk) { stderr += chunk.toString().slice(0, 2000); });
+ child.on('error', reject);
+ child.on('close', function (code) {
+ if (code === 0) return resolve();
+ reject(new Error('deck renderer exited ' + code + (stderr ? ': ' + stderr.trim() : '')));
+ });
+ child.stdin.end(JSON.stringify(spec));
+ });
+ var built = await fsp.readFile(out);
+ if (built.length) return;
+ throw new Error('deck renderer produced an empty file');
+ } catch (err) {
+ console.warn('[export] deck renderer failed, falling back to pandoc:', err.message);
+ await runPandoc(['doc.md', '--reference-doc=' + REFERENCE_DECK, '-o', 'doc.pptx'], workdir);
+ // pandoc leaves a bare on every shape, so its decks still need
+ // autofit injecting or they overflow.
+ await fsp.writeFile(out, await fitSlideText(await fsp.readFile(out)));
+ }
+}
+
// A filename someone can find again, without letting a title choose the path.
function filename(title, format) {
var safe = String(title || 'resource')
diff --git a/src/utils/slideSpec.js b/src/utils/slideSpec.js
new file mode 100644
index 00000000..fb5af5dc
--- /dev/null
+++ b/src/utils/slideSpec.js
@@ -0,0 +1,166 @@
+// ============================================================
+// MARKDOWN → SLIDE SPEC
+// ============================================================
+// The half of deck rendering that decides what each slide *is*. The Python
+// renderer draws whatever it is handed; this is where a slide becomes a table
+// slide rather than a bullet slide, where a long list becomes two columns, and
+// where a generated figure gets a slide of its own.
+//
+// Markdown stays the stored artifact, so "change slide 4" remains a text edit
+// and Word export is unaffected. Nothing here invents content: every slide comes
+// from a heading that the author or the model wrote.
+
+// A bullet list longer than this reads as a wall of text at any font size that
+// is still legible, so it is split across two columns instead.
+var TWO_COLUMN_AT = 7;
+
+function parseTitleBlock(lines) {
+ // Pandoc's convention: up to three leading lines beginning with %.
+ var meta = [];
+ while (lines.length && /^%\s*/.test(lines[0]) && meta.length < 3) {
+ meta.push(lines.shift().replace(/^%\s*/, '').trim());
+ }
+ return { title: meta[0] || '', subtitle: meta[1] || '', date: meta[2] || '' };
+}
+
+function splitSlides(lines) {
+ var slides = [];
+ var current = null;
+ for (var i = 0; i < lines.length; i++) {
+ var line = lines[i];
+ var heading = /^#\s+(.*)$/.exec(line);
+ if (heading) {
+ if (current) slides.push(current);
+ current = { heading: heading[1].trim(), body: [] };
+ continue;
+ }
+ // A level-2 heading inside a deck is a subheading, not a new slide; keep it
+ // as emphasised text so nothing is silently dropped.
+ if (current) current.body.push(line);
+ }
+ if (current) slides.push(current);
+ return slides;
+}
+
+function parseTable(body) {
+ var rows = body.filter(function (l) { return /^\s*\|/.test(l); });
+ if (rows.length < 2) return null;
+ function cells(line) {
+ return line.trim().replace(/^\||\|$/g, '').split('|').map(function (c) { return c.trim(); });
+ }
+ var header = cells(rows[0]);
+ // The second row of a pipe table is the alignment rule, not data.
+ var start = /^[\s|:-]+$/.test(rows[1]) ? 2 : 1;
+ var data = rows.slice(start).map(cells).filter(function (r) {
+ return r.some(function (c) { return c; });
+ });
+ if (!data.length) return null;
+ return { header: header, rows: data };
+}
+
+function parseBullets(body) {
+ var items = [];
+ for (var i = 0; i < body.length; i++) {
+ var line = body[i];
+ var bullet = /^(\s*)(?:[-*+]|\d+\.)\s+(.*)$/.exec(line);
+ if (bullet) {
+ items.push({ text: bullet[2].trim(), level: Math.floor(bullet[1].length / 2) });
+ continue;
+ }
+ var sub = /^##+\s+(.*)$/.exec(line);
+ if (sub) { items.push({ text: '**' + sub[1].trim() + '**', level: 0 }); continue; }
+ var text = line.trim();
+ if (!text) continue;
+ // A paragraph on a slide is still something to show. Continuations of the
+ // previous bullet are folded into it rather than becoming a stray line.
+ if (items.length && !/^[A-Z0-9"'(]/.test(text) && items[items.length - 1].text.length < 200) {
+ items[items.length - 1].text += ' ' + text;
+ } else {
+ items.push({ text: text, level: 0 });
+ }
+ }
+ return items;
+}
+
+// Speaker notes, if the author used the convention. Never guessed at.
+function pullNotes(body) {
+ var notes = [];
+ var rest = [];
+ for (var i = 0; i < body.length; i++) {
+ var note = /^(?:>|Notes?:)\s*(.*)$/i.exec(body[i]);
+ if (note) notes.push(note[1].trim());
+ else rest.push(body[i]);
+ }
+ return { notes: notes.join('\n'), body: rest };
+}
+
+function halve(items) {
+ // Split on a top-level boundary so a sub-bullet never leads a column.
+ var target = Math.ceil(items.length / 2);
+ var cut = target;
+ for (var i = target; i < items.length; i++) {
+ if (!items[i].level) { cut = i; break; }
+ }
+ return [items.slice(0, cut), items.slice(cut)];
+}
+
+/**
+ * Build the renderer's input from markdown, plus any figures to place.
+ *
+ * `images` are absolute paths; each becomes a slide of its own placed after the
+ * slide whose position matches, so a three-figure deck spreads them out rather
+ * than stacking them at the end.
+ */
+function build(markdown, options) {
+ options = options || {};
+ var lines = String(markdown || '').replace(/\r\n/g, '\n').split('\n');
+ var meta = parseTitleBlock(lines);
+ var raw = splitSlides(lines);
+
+ var slides = [];
+ if (meta.title) {
+ slides.push({ type: 'title', heading: meta.title, subtitle: meta.subtitle, date: meta.date });
+ }
+
+ var content = [];
+ raw.forEach(function (slide) {
+ var pulled = pullNotes(slide.body);
+ var table = parseTable(pulled.body);
+ if (table) {
+ content.push({ type: 'table', heading: slide.heading, header: table.header,
+ rows: table.rows, notes: pulled.notes });
+ return;
+ }
+ var items = parseBullets(pulled.body);
+ if (!items.length) {
+ content.push({ type: 'section', heading: slide.heading });
+ return;
+ }
+ if (items.length > TWO_COLUMN_AT) {
+ var columns = halve(items);
+ content.push({ type: 'two', heading: slide.heading, left: columns[0],
+ right: columns[1], notes: pulled.notes });
+ return;
+ }
+ content.push({ type: 'bullets', heading: slide.heading, bullets: items, notes: pulled.notes });
+ });
+
+ // Figures are spread through the deck rather than appended: a deck that ends
+ // with three unexplained pictures is worse than one that shows each near the
+ // material it illustrates. References, if present, stay last.
+ var images = (options.images || []).filter(Boolean);
+ if (images.length && content.length) {
+ var tail = content.length;
+ while (tail > 0 && /^references$/i.test(String(content[tail - 1].heading || '').trim())) tail--;
+ var step = Math.max(1, Math.floor(tail / (images.length + 1)));
+ for (var i = images.length - 1; i >= 0; i--) {
+ var at = Math.min(tail, Math.max(1, step * (i + 1)));
+ content.splice(at, 0, { type: 'image', heading: '', image: images[i], caption: '' });
+ }
+ }
+
+ return { title: meta.title, subtitle: meta.subtitle, date: meta.date,
+ slides: slides.concat(content) };
+}
+
+module.exports = { build, TWO_COLUMN_AT };
diff --git a/test/my-resources.test.js b/test/my-resources.test.js
index a59b7d85..83bd08b9 100644
--- a/test/my-resources.test.js
+++ b/test/my-resources.test.js
@@ -36,7 +36,7 @@ test('markdown is the artifact; every format is rendered from it', () => {
const exporter = read('src/utils/documentExport.js');
// Refining means editing text, never patching a binary — which is what makes
// "change slide 4" possible at all.
- assert.match(exporter, /async function render\(markdown, kind, format\)/);
+ assert.match(exporter, /async function render\(markdown, kind, format, options\)/);
assert.match(exporter, /var office = kind === 'presentation' \? 'pptx' : 'docx';/);
assert.match(exporter, /--reference-doc=' \+ REFERENCE_DECK/, 'decks keep the house template');
@@ -306,8 +306,9 @@ test('a slide shrinks its text rather than spilling off the bottom', () => {
assert.doesNotMatch(exporter, /normAutofit fontScale/);
// Running it on a deck that already has autofit must not double-inject.
assert.match(exporter, /if \(xml\.indexOf\('normAutofit'\) !== -1\) continue;/);
- // And it applies to the PDF path too, which renders from the pptx.
- assert.match(exporter, /if \(office === 'pptx'\) bytes = await fitSlideText\(bytes\);/);
+ // It is now only needed on the pandoc fallback: the Python renderer sizes
+ // text to fit before writing the file, so its decks never need patching.
+ assert.match(exporter, /await fsp\.writeFile\(out, await fitSlideText\(await fsp\.readFile\(out\)\)\);/);
// A deck that renders imperfectly beats no deck at all.
assert.match(exporter, /could not apply slide autofit/);
assert.ok(JSON.parse(read('package.json')).dependencies.jszip, 'jszip is declared, not borrowed');
diff --git a/test/slide-spec.test.js b/test/slide-spec.test.js
new file mode 100644
index 00000000..0e184b79
--- /dev/null
+++ b/test/slide-spec.test.js
@@ -0,0 +1,106 @@
+// ============================================================
+// SLIDE SPEC
+// ============================================================
+// The half of deck rendering that decides what each slide is. Pandoc had no
+// such step — every slide became bullets on a reference layout — so this is
+// where most of the difference in a generated deck now comes from.
+
+const test = require('node:test');
+const assert = require('node:assert');
+const fs = require('fs');
+const path = require('path');
+const slideSpec = require('../src/utils/slideSpec');
+const read = p => fs.readFileSync(path.join(__dirname, '..', p), 'utf8');
+
+const DECK = [
+ '% Croup in Children', '% Teaching Resource', '% 2026', '',
+ '# What Croup Is', '', '- A viral illness', '- Peaks at 12-18 months', '',
+ '# Severity', '',
+ '| Feature | Mild | Severe |', '|---|---|---|',
+ '| Stridor | Absent | Present |', '| Retractions | None | Marked |', '',
+ '# Long list', '',
+ '- one', '- two', '- three', '- four', '- five', '- six', '- seven', '- eight', '',
+ '# References', '', '- Nelson, p. 2606'
+].join('\n');
+
+test('a title block becomes a title slide, not a bullet', () => {
+ const spec = slideSpec.build(DECK, {});
+ assert.equal(spec.slides[0].type, 'title');
+ assert.equal(spec.slides[0].heading, 'Croup in Children');
+ assert.equal(spec.slides[0].subtitle, 'Teaching Resource');
+ assert.equal(spec.slides[0].date, '2026');
+});
+
+test('each slide gets the layout its content needs', () => {
+ const byHeading = {};
+ slideSpec.build(DECK, {}).slides.forEach(s => { if (s.heading) byHeading[s.heading] = s; });
+
+ assert.equal(byHeading['What Croup Is'].type, 'bullets');
+ // A pipe table is a table, not eight lines of text with pipes in them.
+ assert.equal(byHeading['Severity'].type, 'table');
+ assert.deepEqual(byHeading['Severity'].header, ['Feature', 'Mild', 'Severe']);
+ assert.equal(byHeading['Severity'].rows.length, 2, 'the alignment rule is not a row');
+ // A long list is unreadable at any legible size in one column.
+ assert.equal(byHeading['Long list'].type, 'two');
+ assert.equal(byHeading['Long list'].left.length + byHeading['Long list'].right.length, 8);
+});
+
+test('figures are spread through the deck, and References stays last', () => {
+ const spec = slideSpec.build(DECK, { images: ['/tmp/a.png', '/tmp/b.png'] });
+ const types = spec.slides.map(s => s.type);
+ assert.equal(types.filter(t => t === 'image').length, 2);
+ // Appending them would end the deck with unexplained pictures.
+ assert.notEqual(types[types.length - 1], 'image');
+ assert.equal(spec.slides[spec.slides.length - 1].heading, 'References');
+ // And never before the first content slide.
+ assert.ok(types.indexOf('image') > 1);
+});
+
+test('a slide with no list still becomes a slide', () => {
+ const spec = slideSpec.build('# Just a heading\n', {});
+ assert.equal(spec.slides[0].type, 'section');
+ assert.equal(spec.slides[0].heading, 'Just a heading');
+});
+
+test('the renderer sizes text to fit rather than trusting autofit', () => {
+ const py = read('scripts/render_pptx.py');
+ // LibreOffice ignores when converting to PDF, which is how
+ // slides were being cut off mid-sentence.
+ assert.match(py, /def _fit_size\(/);
+ assert.match(py, /BULLET_SIZES = /);
+ // 16:9. Pandoc's reference doc is 4:3.
+ assert.match(py, /SLIDE_W = Emu\(12192000\)/);
+ // An image keeps its own aspect ratio; the old pptxgenjs path stretched every
+ // one of them to the target box.
+ assert.match(py, /ratio = img\.width \/ float\(img\.height\)/);
+ assert.match(py, /width = int\(height \* ratio\)/);
+ // Wrapped lines hang under the text.
+ assert.match(py, /pPr\.set\("indent", str\(-indent\)\)/);
+});
+
+test('the deck renderer replaces pandoc, and pandoc still catches it if it falls', () => {
+ const exporter = read('src/utils/documentExport.js');
+ assert.match(exporter, /async function buildDeck\(markdown, workdir, images\)/);
+ assert.match(exporter, /spawn\('python3', \[DECK_RENDERER, out\]/);
+ // A plainer deck beats a failed download.
+ assert.match(exporter, /deck renderer failed, falling back to pandoc/);
+ assert.match(exporter, /runPandoc\(\['doc\.md', '--reference-doc=' \+ REFERENCE_DECK/);
+ // Word is still pandoc's, where its output is good.
+ assert.match(exporter, /runPandoc\(\['doc\.md', '-o', 'doc\.docx'\]/);
+ // And the runtime actually has it.
+ assert.match(read('Dockerfile'), /python-pptx==1\.0\.2/);
+});
+
+test('a resource remembers its figures, so an export can include them', () => {
+ const route = read('src/routes/myResources.js');
+ // They were queued and shown on screen, but nothing tied them to the
+ // resource, so an exported deck could never contain them.
+ assert.match(read('migrations/1780500000000_resource-images.js'), /image_ids JSONB/);
+ assert.match(route, /var figureIds = \(ai\.imageJobs \|\| \[\]\)\.map/);
+ // "Add two more diagrams" means more, not instead.
+ assert.match(route, /image_ids = image_ids \|\| \?::jsonb/);
+ assert.match(route, /async function collectFigures\(ids, user, dir\)/);
+ // A figure that cannot be fetched is left out rather than failing a download.
+ assert.match(route, /figure unavailable for export/);
+ assert.match(route, /documentExport\.render\(row\.markdown, row\.kind, format, \{ images: figures \}\)/);
+});