From 154b896d5b2215bee8736f72a49dc8afdff9c902 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 11 Sep 2026 21:58:53 +0200 Subject: [PATCH] feat: Word is built by python-docx from the same typed source as the deck MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pandoc reads markdown, so every Word export had to flatten the resource to markdown first — and a deck flattened to markdown stops being one. A comparison became two headings and two lists, a callout became bold text, and a figure became nothing at all, because markdown has nowhere to put it. src/utils/docSpec.js reduces either source to the same blocks: a stored deck where there is one, the markdown where there is not. scripts/render_docx.py draws them. A comparison comes out as a labelled two-column table, a callout as a shaded box, a table as a real table, a figure embedded at its own aspect ratio with its caption, and speaker notes as muted indented text. The deck wins over the markdown beside it, because that markdown is a serialisation of the deck and reading it instead would be reading a lossy copy of what is right there. Word now carries the figures too. The export route skipped fetching them for docx, which was correct when pandoc could not place them and wrong the moment this could. Pandoc stays installed and stays the fallback: a plainer document beats a failed download. Both renderers now share one spawn helper. Verified end to end: a deck with two figures exported as a six-page Word document with both images embedded (537KB, two files in word/media), rendered to PDF and looked at — the comparison is a labelled table, the figure sits at its true aspect ratio, and the notes read as notes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU --- Dockerfile | 4 +- docs/deployment.md | 4 +- docs/my-resources.md | 19 ++- scripts/render_docx.py | 226 ++++++++++++++++++++++++++++++++++++ src/routes/myResources.js | 10 +- src/utils/docSpec.js | 149 ++++++++++++++++++++++++ src/utils/documentExport.js | 68 ++++++++--- test/doc-spec.test.js | 87 ++++++++++++++ test/slide-spec.test.js | 4 +- 9 files changed, 541 insertions(+), 30 deletions(-) create mode 100644 scripts/render_docx.py create mode 100644 src/utils/docSpec.js create mode 100644 test/doc-spec.test.js diff --git a/Dockerfile b/Dockerfile index 9ebfdbbe..59e6cdb9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -37,8 +37,8 @@ RUN apk add --no-cache ffmpeg curl jq pandoc-cli RUN apk add --no-cache poppler-utils 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' + && pip install --break-system-packages --no-cache-dir python-pptx==1.0.2 python-docx==1.1.2 \ + && python3 -c 'import pptx, docx' # Pull the bao CLI out of the upstream image — matches host arch because # buildx pulls the right manifest-list variant per build. diff --git a/docs/deployment.md b/docs/deployment.md index 70a8f701..a5a07329 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -13,9 +13,9 @@ on. They are in `Dockerfile` and worth knowing about before trimming it: | | For | |---|---| -| `pandoc-cli` | Word (`.docx`) export | +| `pandoc-cli` | the fallback for Word export when the renderer cannot run | | `python3`, `py3-lxml`, `py3-pillow` | the slide renderer. Both libraries are C extensions with no Alpine wheels, so they come from apk rather than pip — installing them from source would mean carrying a compiler in the runtime image | -| `python-pptx==1.0.2` (pip) | builds the decks. Pinned: unpinned, a rebuild from the same commit could produce different slides | +| `python-pptx==1.0.2`, `python-docx==1.1.2` (pip) | build the decks and the documents. Pinned: unpinned, a rebuild from the same commit could produce different output | | `poppler-utils` | `pdftoppm`, which turns a rendered deck into one image per slide so a vision model can see it. Only needed when slide review is switched on | | `ffmpeg`, `curl`, `jq` | audio handling and entrypoint scripting | diff --git a/docs/my-resources.md b/docs/my-resources.md index e0d51232..83ed321c 100644 --- a/docs/my-resources.md +++ b/docs/my-resources.md @@ -140,9 +140,19 @@ treated as missing whatever its length. | Format | Built by | |---|---| | `pptx` | `scripts/render_pptx.py` (python-pptx) from the stored deck | -| `docx` | pandoc, from the markdown | +| `docx` | `scripts/render_docx.py` (python-docx) from the same typed source | | `pdf` | Gotenberg (LibreOffice), from whichever office file above | +Both office formats come from `src/utils/docSpec.js` / `slideSpec.js` rather than +from markdown. Pandoc reads markdown, so a deck had to be flattened first — and a +flattened deck stops being one: a comparison became two headings and two lists, a +callout became bold text, and a figure became nothing at all. From the typed +source a comparison is a labelled two-column table, a callout is a shaded box, +and a figure is embedded at its own aspect ratio with its caption. An article, +which has no deck, is parsed from its markdown into the same blocks. + +Pandoc is still installed and is still the fallback for Word. + Pandoc's pptx writer was the ceiling on how good a deck could be, and the model on top made no difference to it: a handful of reference layouts, no per-slide layout, no positioning, no control over how large an image is drawn. It also @@ -163,9 +173,10 @@ afterwards. One that cannot be fetched is left out rather than failing a download that works without it. **Runtime dependency:** the image carries `python3`, `py3-lxml`, `py3-pillow` -(apk — both are C extensions with no Alpine wheels) and `python-pptx` pinned at -1.0.2 from pip. Roughly 58MB. Unpinned, a rebuild from the same commit could -produce different decks. +(apk — both are C extensions with no Alpine wheels), plus `python-pptx` 1.0.2 and +`python-docx` 1.1.2 from pip, and `poppler-utils` for slide review. Roughly 58MB +of Python. Both pip packages are pinned: unpinned, a rebuild from the same commit +could produce different documents. ## Slide review diff --git a/scripts/render_docx.py b/scripts/render_docx.py new file mode 100644 index 00000000..5e0f5103 --- /dev/null +++ b/scripts/render_docx.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python3 +"""Render a document from a JSON spec on stdin to a .docx file. + +Replaces pandoc for this path. Pandoc reads markdown, so everything had to be +flattened to markdown first — and a deck flattened to markdown loses what made +it a deck: a comparison became two headings and two lists, a callout became bold +text, a figure became nothing at all. Coming from the typed spec, a comparison +is a two-column table, a callout is a shaded box, and a figure keeps its caption. + +Input (stdin, JSON): + {"title": str, "subtitle": str, "date": str, "blocks": [ ... ]} + +Blocks: + {"type":"heading","level":1..4,"text":str} + {"type":"para","text":str,"muted":bool} + {"type":"bullets","items":[{"text":str,"level":0..4}]} + {"type":"table","header":[str],"rows":[[str]]} + {"type":"callout","text":str} + {"type":"image","path":str,"caption":str} + +Output: argv[1]. Errors to stderr, non-zero exit, so the caller can fall back. +""" +import json +import os +import re +import sys + +from docx import Document +from docx.enum.table import WD_TABLE_ALIGNMENT +from docx.enum.text import WD_ALIGN_PARAGRAPH +from docx.oxml import OxmlElement +from docx.oxml.ns import qn +from docx.shared import Emu, Pt, RGBColor + +try: + from PIL import Image +except Exception: # pragma: no cover + Image = None + +INK = RGBColor(0x1F, 0x29, 0x37) +MUTED = RGBColor(0x4B, 0x55, 0x63) +ACCENT = RGBColor(0x25, 0x63, 0xEB) +CALLOUT_INK = RGBColor(0x78, 0x35, 0x0F) + +BODY_FONT = "Calibri" +CONTENT_WIDTH_EMU = Emu(5486400) # 6" — A4/Letter with 1" margins + + +def shade(element, hex_fill): + """Cell or paragraph shading. python-docx exposes no API for either.""" + shd = OxmlElement("w:shd") + shd.set(qn("w:val"), "clear") + shd.set(qn("w:color"), "auto") + shd.set(qn("w:fill"), hex_fill) + element.append(shd) + + +def style_body(document): + normal = document.styles["Normal"] + normal.font.name = BODY_FONT + normal.font.size = Pt(11) + normal.font.color.rgb = INK + normal.paragraph_format.space_after = Pt(8) + normal.paragraph_format.line_spacing = 1.15 + + +def add_runs(paragraph, text, bold=False, color=None, size=None): + """Inline **bold** and *italic* survive; everything else is literal.""" + for chunk, is_bold, is_italic in split_inline(text): + run = paragraph.add_run(chunk) + run.bold = bold or is_bold + run.italic = is_italic + run.font.name = BODY_FONT + if color is not None: + run.font.color.rgb = color + if size is not None: + run.font.size = Pt(size) + + +def split_inline(text): + out = [] + pattern = re.compile(r"(\*\*|__)(.+?)\1|(\*|_)(.+?)\3") + index = 0 + for match in pattern.finditer(text or ""): + if match.start() > index: + out.append((text[index:match.start()], False, False)) + if match.group(2) is not None: + out.append((match.group(2), True, False)) + else: + out.append((match.group(4), False, True)) + index = match.end() + if index < len(text or ""): + out.append((text[index:], False, False)) + return out or [(text or "", False, False)] + + +def heading(document, spec): + level = max(1, min(int(spec.get("level") or 2), 4)) + para = document.add_paragraph() + para.paragraph_format.space_before = Pt(16 if level <= 2 else 12) + para.paragraph_format.space_after = Pt(6) + para.paragraph_format.keep_with_next = True + sizes = {1: 18, 2: 14, 3: 12, 4: 11} + add_runs(para, spec.get("text") or "", bold=True, + color=ACCENT if level == 1 else INK, size=sizes[level]) + + +def para(document, spec): + p = document.add_paragraph() + add_runs(p, spec.get("text") or "", color=MUTED if spec.get("muted") else None, + size=10 if spec.get("muted") else None) + if spec.get("muted"): + p.paragraph_format.left_indent = Emu(228600) + + +def bullets(document, spec): + for item in spec.get("items") or []: + text = (item.get("text") or "").strip() + if not text: + continue + level = max(0, min(int(item.get("level") or 0), 4)) + p = document.add_paragraph(style="List Bullet" if level == 0 else "List Bullet 2") + p.paragraph_format.left_indent = Emu(228600 * (level + 1)) + p.paragraph_format.space_after = Pt(4) + add_runs(p, text) + + +def table(document, spec): + header = spec.get("header") or [] + rows = spec.get("rows") or [] + if not rows: + return + cols = max(len(header), max((len(r) for r in rows), default=1)) + t = document.add_table(rows=len(rows) + (1 if header else 0), cols=cols) + t.style = "Table Grid" + t.alignment = WD_TABLE_ALIGNMENT.CENTER + offset = 0 + if header: + for c in range(cols): + cell = t.cell(0, c) + cell.text = "" + add_runs(cell.paragraphs[0], header[c] if c < len(header) else "", bold=True, size=10) + shade(cell._tc.get_or_add_tcPr(), "E7EEFC") + offset = 1 + for r, row in enumerate(rows): + for c in range(cols): + cell = t.cell(r + offset, c) + cell.text = "" + add_runs(cell.paragraphs[0], row[c] if c < len(row) else "", size=10) + document.add_paragraph().paragraph_format.space_after = Pt(4) + + +def callout(document, spec): + """One thing worth stopping on, in a shaded box with a rule down its side.""" + t = document.add_table(rows=1, cols=1) + t.alignment = WD_TABLE_ALIGNMENT.CENTER + cell = t.cell(0, 0) + cell.text = "" + shade(cell._tc.get_or_add_tcPr(), "FEF3C7") + add_runs(cell.paragraphs[0], spec.get("text") or "", bold=True, color=CALLOUT_INK) + document.add_paragraph().paragraph_format.space_after = Pt(4) + + +def image(document, spec): + path = spec.get("path") + if not path or not os.path.exists(path): + return + width = CONTENT_WIDTH_EMU + if Image is not None: + try: + with Image.open(path) as img: + # A tall figure at full width runs off the page; cap the height + # and let the width follow rather than stretching either. + if img.width and img.height and (img.height / img.width) > 1.1: + width = Emu(int(CONTENT_WIDTH_EMU * 0.62)) + except Exception: + pass + document.add_picture(path, width=width) + document.paragraphs[-1].alignment = WD_ALIGN_PARAGRAPH.CENTER + caption = (spec.get("caption") or "").strip() + if caption: + p = document.add_paragraph() + p.alignment = WD_ALIGN_PARAGRAPH.CENTER + add_runs(p, caption, color=MUTED, size=9) + p.runs[0].italic = True + + +BUILDERS = { + "heading": heading, "para": para, "bullets": bullets, + "table": table, "callout": callout, "image": image, +} + + +def main(): + if len(sys.argv) < 2: + print("usage: render_docx.py (spec on stdin)", file=sys.stderr) + return 2 + spec = json.load(sys.stdin) + blocks = spec.get("blocks") or [] + if not blocks: + print("spec contains no blocks", file=sys.stderr) + return 3 + + document = Document() + style_body(document) + + title = document.add_paragraph() + title.paragraph_format.space_after = Pt(2) + add_runs(title, spec.get("title") or "Resource", bold=True, size=24) + for line in [spec.get("subtitle"), spec.get("date")]: + if not line: + continue + sub = document.add_paragraph() + sub.paragraph_format.space_after = Pt(0) + add_runs(sub, line, color=MUTED, size=11) + document.add_paragraph() + + for block in blocks: + BUILDERS.get(block.get("type") or "para", para)(document, block) + + document.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 d15d88d6..a62957ec 100644 --- a/src/routes/myResources.js +++ b/src/routes/myResources.js @@ -586,12 +586,12 @@ router.get('/my-resources/:id/export', async function (req, res) { // 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. + // Every format now carries the figures: Word embeds them too, since it is + // built from the same typed spec rather than from flattened markdown. 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 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, diff --git a/src/utils/docSpec.js b/src/utils/docSpec.js new file mode 100644 index 00000000..67ea2371 --- /dev/null +++ b/src/utils/docSpec.js @@ -0,0 +1,149 @@ +// ============================================================ +// DOCUMENT SPEC +// ============================================================ +// A flat list of blocks a Word renderer can draw, built from either source: the +// typed deck a presentation is stored as, or the markdown an article is. +// +// Going deck → markdown → pandoc lost the structure twice. A comparison became +// two headings and two lists, a callout became bold text, and a table survived +// only because pandoc happens to parse pipe tables. Coming from the deck +// directly, a comparison is a two-column table, a callout is a shaded box, and a +// figure keeps its caption. + +var BLOCK = ['heading', 'para', 'bullets', 'table', 'callout', 'image']; + +function text(value, max) { + return String(value === undefined || value === null ? '' : value).slice(0, max || 4000).trim(); +} + +/** Blocks from a stored deck. Slides become sections of a handout. */ +function fromDeck(deck, images) { + var blocks = []; + var byJob = images || {}; + (deck.slides || []).forEach(function (slide) { + if (slide.type === 'title') return; // the document has its own title page + if (slide.heading) blocks.push({ type: 'heading', level: slide.type === 'section' ? 1 : 2, + text: text(slide.heading, 300) }); + + if (slide.type === 'callout' && slide.text) { + blocks.push({ type: 'callout', text: text(slide.text) }); + } else if (slide.type === 'table') { + blocks.push({ type: 'table', header: (slide.header || []).map(function (c) { return text(c, 300); }), + rows: (slide.rows || []).map(function (row) { + return row.map(function (c) { return text(c, 600); }); + }) }); + } else if (slide.type === 'compare' && (slide.columns || []).length === 2) { + // Side by side stays side by side. Flattened to two lists it stopped + // being a comparison, which was the whole reason for the layout. + var left = slide.columns[0], right = slide.columns[1]; + var depth = Math.max((left.bullets || []).length, (right.bullets || []).length); + var rows = []; + for (var i = 0; i < depth; i++) { + rows.push([text((left.bullets[i] || {}).text, 600), text((right.bullets[i] || {}).text, 600)]); + } + blocks.push({ type: 'table', header: [text(left.label, 120), text(right.label, 120)], rows: rows }); + } else { + var items = (slide.bullets || []).concat(slide.left || []).concat(slide.right || []); + if (items.length) { + blocks.push({ type: 'bullets', items: items.map(function (b) { + return { text: text(b.text, 1200), level: Math.max(0, Math.min(4, b.level || 0)) }; + }) }); + } + } + + if (slide.image_job && byJob[slide.image_job]) { + blocks.push({ type: 'image', path: byJob[slide.image_job], caption: text(slide.caption, 300) }); + } + if (slide.notes) blocks.push({ type: 'para', text: text(slide.notes, 2000), muted: true }); + }); + return blocks; +} + +function parseTable(lines, at) { + var rows = []; + var i = at; + while (i < lines.length && /^\s*\|/.test(lines[i])) { rows.push(lines[i]); i++; } + 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]); + var start = /^[\s|:-]+$/.test(rows[1]) ? 2 : 1; + var body = rows.slice(start).map(cells).filter(function (r) { return r.some(Boolean); }); + if (!body.length) return null; + return { block: { type: 'table', header: header, rows: body }, next: i }; +} + +/** Blocks from markdown. What an article is stored as. */ +function fromMarkdown(markdown) { + var lines = String(markdown || '').replace(/\r\n/g, '\n').split('\n'); + var blocks = []; + var bullets = null; + var para = []; + + function flushPara() { + if (!para.length) return; + blocks.push({ type: 'para', text: para.join(' ').trim() }); + para = []; + } + function flushBullets() { + if (!bullets) return; + blocks.push({ type: 'bullets', items: bullets }); + bullets = null; + } + function flush() { flushPara(); flushBullets(); } + + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (/^%\s*/.test(line)) continue; // pandoc title block + + if (/^\s*\|/.test(line)) { + var table = parseTable(lines, i); + if (table) { flush(); blocks.push(table.block); i = table.next - 1; continue; } + } + + var heading = /^(#{1,6})\s+(.*)$/.exec(line); + if (heading) { flush(); blocks.push({ type: 'heading', level: Math.min(heading[1].length, 4), + text: heading[2].trim() }); continue; } + + var bullet = /^(\s*)(?:[-*+]|\d+\.)\s+(.*)$/.exec(line); + if (bullet) { + flushPara(); + if (!bullets) bullets = []; + bullets.push({ text: bullet[2].trim(), level: Math.min(4, Math.floor(bullet[1].length / 2)) }); + continue; + } + + var quote = /^\s*>\s?(.*)$/.exec(line); + if (quote) { flush(); blocks.push({ type: 'callout', text: quote[1].trim() }); continue; } + + if (/^\s*[-*_]{3,}\s*$/.test(line)) { flush(); continue; } + + if (!line.trim()) { flush(); continue; } + flushBullets(); + para.push(line.trim()); + } + flush(); + return blocks; +} + +/** + * The whole document. `deck` wins when there is one — it is the typed source, + * and the markdown beside it is a serialisation of it. + */ +function build(options) { + var opts = options || {}; + var blocks = opts.deck && (opts.deck.slides || []).length + ? fromDeck(opts.deck, opts.images) + : fromMarkdown(opts.markdown); + var meta = opts.deck || {}; + var first = (opts.markdown || '').match(/^%\s*(.+)$/m); + return { + title: text(meta.title || (first && first[1]) || opts.title || 'Resource', 300), + subtitle: text(meta.subtitle, 300), + date: text(meta.date, 120), + blocks: blocks.filter(function (b) { return BLOCK.indexOf(b.type) !== -1; }) + }; +} + +module.exports = { build, fromDeck, fromMarkdown, BLOCK }; diff --git a/src/utils/documentExport.js b/src/utils/documentExport.js index 39603a1d..6cd8592f 100644 --- a/src/utils/documentExport.js +++ b/src/utils/documentExport.js @@ -18,9 +18,11 @@ var pathMod = require('path'); var { execFile, spawn } = require('child_process'); var JSZip = require('jszip'); var slideSpec = require('./slideSpec'); +var docSpec = require('./docSpec'); var REFERENCE_DECK = pathMod.join(__dirname, '..', '..', 'assets', 'learning', 'slides-reference.pptx'); var DECK_RENDERER = pathMod.join(__dirname, '..', '..', 'scripts', 'render_pptx.py'); +var DOC_RENDERER = pathMod.join(__dirname, '..', '..', 'scripts', 'render_docx.py'); var GOTENBERG = process.env.GOTENBERG_URL || 'http://gotenberg:3000'; var FORMATS = { @@ -98,7 +100,7 @@ async function render(markdown, kind, format, options) { var office = kind === 'presentation' ? 'pptx' : 'docx'; if (office === 'docx' || format === 'docx') { - await runPandoc(['doc.md', '-o', 'doc.docx'], workdir); + await buildDoc(markdown, workdir, options); } if (office === 'pptx' || format === 'pptx') { await buildDeck(markdown, workdir, options.images || [], options); @@ -133,6 +135,53 @@ async function render(markdown, kind, format, options) { // // If the renderer fails for any reason, pandoc still produces a deck. A plainer // deck beats a failed download. +function runRenderer(script, out, spec, workdir) { + return new Promise(function (resolve, reject) { + var child = spawn('python3', [script, 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(pathMod.basename(script) + ' exited ' + code + (stderr ? ': ' + stderr.trim() : ''))); + }); + child.stdin.end(JSON.stringify(spec)); + }); +} + +// ── Word ──────────────────────────────────────────────────── +// Also python, for the same reason as the deck. Pandoc reads markdown, so a deck +// had to be flattened to markdown first — and a deck flattened to markdown stops +// being one: a comparison became two headings and two lists, a callout became +// bold text, and a figure became nothing at all. Built from the typed spec, a +// comparison is a two-column table, a callout is a shaded box, and a figure +// keeps its caption. +// +// Pandoc remains the fallback. A plainer document beats a failed download. +async function buildDoc(markdown, workdir, options) { + var out = pathMod.join(workdir, 'doc.docx'); + try { + var spec = docSpec.build({ + deck: options.deck, markdown: markdown, + images: figuresByJob(options.images, options.figureIds) + }); + if (!spec.blocks.length) throw new Error('nothing to render'); + await runRenderer(DOC_RENDERER, out, spec, workdir); + if (!(await fsp.readFile(out)).length) throw new Error('the renderer produced an empty file'); + } catch (err) { + console.warn('[export] document renderer failed, falling back to pandoc:', err.message); + await runPandoc(['doc.md', '-o', 'doc.docx'], workdir); + } +} + +function figuresByJob(files, figureIds) { + var byJob = {}; + (figureIds || []).forEach(function (id, index) { + if (files && files[index]) byJob[id] = files[index]; + }); + return byJob; +} + async function buildDeck(markdown, workdir, images, options) { options = options || {}; var out = pathMod.join(workdir, 'doc.pptx'); @@ -143,17 +192,7 @@ async function buildDeck(markdown, workdir, images, options) { var spec = options.deck ? attachFigures(options.deck, images, options.figureIds) : 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)); - }); + await runRenderer(DECK_RENDERER, out, spec, workdir); var built = await fsp.readFile(out); if (built.length) return; throw new Error('deck renderer produced an empty file'); @@ -170,10 +209,7 @@ async function buildDeck(markdown, workdir, images, options) { // arrive in the order the jobs were created, which is the order the slides // requested them, so a slide is matched by its job id rather than by position. function attachFigures(deck, files, figureIds) { - var byJob = {}; - (figureIds || []).forEach(function (id, index) { - if (files[index]) byJob[id] = files[index]; - }); + var byJob = figuresByJob(files, figureIds); var slides = (deck.slides || []).map(function (slide) { var copy = Object.assign({}, slide); // The renderer reads whatever path this field holds and embeds that file in diff --git a/test/doc-spec.test.js b/test/doc-spec.test.js new file mode 100644 index 00000000..5b3c6928 --- /dev/null +++ b/test/doc-spec.test.js @@ -0,0 +1,87 @@ +// ============================================================ +// DOCUMENT SPEC +// ============================================================ +// What Word is built from. Going deck → markdown → pandoc lost the structure +// twice; this is the typed source both a deck and an article reduce to. + +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); +const docSpec = require('../src/utils/docSpec'); +const read = p => fs.readFileSync(path.join(__dirname, '..', p), 'utf8'); + +const DECK = { title: 'Croup', subtitle: 'Teaching', date: '2026', slides: [ + { type: 'title', heading: 'Croup' }, + { type: 'bullets', heading: 'What it is', bullets: [{ text: 'Viral', level: 0 }, { text: 'Peaks 12-18m', level: 1 }] }, + { type: 'compare', heading: 'Versus', columns: [ + { label: 'CROUP', bullets: [{ text: 'Barking cough' }, { text: 'Hoarse' }] }, + { label: 'EPIGLOTTITIS', bullets: [{ text: 'Drooling' }] }] }, + { type: 'table', heading: 'Features', header: ['Feature', 'Mild'], rows: [['Stridor', 'Absent']] }, + { type: 'callout', heading: 'Red flag', text: 'Do not examine the throat.' }, + { type: 'figure', heading: 'Anatomy', bullets: [{ text: 'Subglottis' }], image_job: 'job-a', caption: 'Airway' }, +]}; + +test('a deck keeps the shape it was designed with', () => { + const spec = docSpec.build({ deck: DECK, images: { 'job-a': '/tmp/fig.png' } }); + const types = spec.blocks.map(b => b.type); + + // The title slide is not a section of the document; the document has a title. + assert.equal(types.filter(t => t === 'heading').length, 5); + assert.equal(spec.title, 'Croup'); + + // A comparison stays a comparison. Flattened to two headings and two lists it + // stopped being one, which was the whole point of the layout. + const compare = spec.blocks[3]; + assert.equal(compare.type, 'table'); + assert.deepEqual(compare.header, ['CROUP', 'EPIGLOTTITIS']); + assert.deepEqual(compare.rows, [['Barking cough', 'Drooling'], ['Hoarse', '']]); + + assert.ok(types.includes('callout'), 'a callout is not bold text'); + const image = spec.blocks.find(b => b.type === 'image'); + assert.equal(image.path, '/tmp/fig.png'); + assert.equal(image.caption, 'Airway'); +}); + +test('a figure with no file is simply absent, not an empty frame', () => { + const spec = docSpec.build({ deck: DECK, images: {} }); + assert.equal(spec.blocks.filter(b => b.type === 'image').length, 0); + // Its heading and bullets still render — the slide had more than a picture. + assert.ok(spec.blocks.some(b => b.type === 'bullets' && b.items[0].text === 'Subglottis')); +}); + +test('an article comes from its markdown', () => { + const spec = docSpec.build({ markdown: [ + '% An article', '', '# Title', '', 'Some prose here.', '', + '- one', '- two', '', '| A | B |', '|---|---|', '| 1 | 2 |', '', '> A quote.', + ].join('\n') }); + assert.equal(spec.title, 'An article', 'the pandoc title block names the document'); + assert.deepEqual(spec.blocks.map(b => b.type), ['heading', 'para', 'bullets', 'table', 'callout']); + assert.deepEqual(spec.blocks[3].rows, [['1', '2']], 'the alignment rule is not a row'); +}); + +test('the deck wins when there is one', () => { + // The markdown beside a deck is a serialisation of it, so reading the + // serialisation instead would be reading a lossy copy of what is right there. + const spec = docSpec.build({ deck: DECK, markdown: '# Something else\n\n- ignored', images: {} }); + assert.ok(spec.blocks.some(b => b.type === 'callout')); + assert.ok(!spec.blocks.some(b => b.type === 'bullets' && b.items[0].text === 'ignored')); +}); + +test('Word is rendered by python-docx, with pandoc still catching it', () => { + const exporter = read('src/utils/documentExport.js'); + assert.match(exporter, /async function buildDoc\(markdown, workdir, options\)/); + assert.match(exporter, /runRenderer\(DOC_RENDERER, out, spec, workdir\)/); + // A plainer document beats a failed download. + assert.match(exporter, /document renderer failed, falling back to pandoc/); + assert.match(exporter, /runPandoc\(\['doc\.md', '-o', 'doc\.docx'\]/); + // Every format carries the figures now, Word included. + assert.doesNotMatch(read('src/routes/myResources.js'), /if \(format !== 'docx'\) \{/); + assert.match(read('Dockerfile'), /python-docx==1\.1\.2/); + + const py = read('scripts/render_docx.py'); + // python-docx exposes no API for shading, so it is written onto the XML. + assert.match(py, /def shade\(element, hex_fill\)/); + // A tall figure at full width runs off the page. + assert.match(py, /\(img\.height \/ img\.width\) > 1\.1/); +}); diff --git a/test/slide-spec.test.js b/test/slide-spec.test.js index dc6a1f3a..735c450e 100644 --- a/test/slide-spec.test.js +++ b/test/slide-spec.test.js @@ -81,7 +81,9 @@ test('the renderer sizes text to fit rather than trusting autofit', () => { 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, options\)/); - assert.match(exporter, /spawn\('python3', \[DECK_RENDERER, out\]/); + // One spawn helper, now that the document renderer uses it too. + assert.match(exporter, /runRenderer\(DECK_RENDERER, out, spec, workdir\)/); + assert.match(exporter, /spawn\('python3', \[script, 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/);