From 73ce4049d416ee69f4d5f02d08071a99e7bf1958 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 11 Sep 2026 19:34:26 +0200 Subject: [PATCH] feat: decks are built with python-pptx instead of pandoc, and carry their figures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pandoc's pptx writer was the ceiling on how good a generated deck could be, and the model on top made no difference to it. It maps markdown onto a handful of reference layouts with no per-slide layout, no positioning and no control over how large an image is drawn, which is why every deck came out as bullets on a template — and why autofit had to be injected into its emitted OOXML by hand afterwards, because LibreOffice ignores the autofit pandoc leaves off. scripts/render_pptx.py draws the deck and src/utils/slideSpec.js decides what each slide is. Markdown stays the stored artifact, so "change slide 4" is still a text edit and Word export is untouched — pandoc still writes docx, where its output is good. What that buys, all of it visible in a rendered deck rather than argued for: - 16:9, not pandoc's 4:3. - A pipe table becomes a real table with a header band and banded rows, not eight lines of text with pipes in them. - A list longer than seven items becomes two columns instead of a wall of text. - Text is measured and sized to fit before the file is written, so nothing depends on a renderer honouring autofit. - Wrapped lines hang under the text instead of running back to the margin, which is the clearest single tell that a deck was generated. - An image is drawn at its own aspect ratio, centred, with a caption. Figures now reach the deck at all, which they never did. They were queued and shown on the page, but nothing recorded that they belonged to the resource, so an export could not include them: user_resources.image_ids holds them, a modification adds to that list rather than replacing it, and export fetches the finished ones to a scratch directory. They are spread through the deck rather than appended, because ending on three unexplained pictures is worse than showing each near its material, and a References slide stays last. If the renderer fails for any reason, pandoc still produces a deck — a plainer deck beats a failed download. Verified end to end: a seven-slide request with three figures exported as a 13-page deck; the slides were rendered to PDF, rasterised and looked at. All three formats still download. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU --- Dockerfile | 14 + migrations/1780500000000_resource-images.js | 18 + scripts/render_pptx.py | 365 ++++++++++++++++++++ src/routes/myResources.js | 63 +++- src/utils/documentExport.js | 63 +++- src/utils/slideSpec.js | 166 +++++++++ test/my-resources.test.js | 7 +- test/slide-spec.test.js | 106 ++++++ 8 files changed, 777 insertions(+), 25 deletions(-) create mode 100644 migrations/1780500000000_resource-images.js create mode 100644 scripts/render_pptx.py create mode 100644 src/utils/slideSpec.js create mode 100644 test/slide-spec.test.js 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 \}\)/); +});