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 \}\)/); +});