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/);