feat: Word is built by python-docx from the same typed source as the deck
Some checks failed
Forgejo Docker Build / Build Docker image (push) Blocked by required conditions
Forgejo Docker Build / Deploy to the host (push) Blocked by required conditions
Forgejo Android APK / Root app tests (push) Successful in 58s
Forgejo Docker Build / Root app tests (push) Successful in 49s
Forgejo Android APK / Build signed APK (push) Has been cancelled

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
This commit is contained in:
Daniel 2026-09-11 21:58:53 +02:00
parent 9d307fd442
commit 154b896d5b
9 changed files with 541 additions and 30 deletions

View file

@ -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.

View file

@ -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 |

View file

@ -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

226
scripts/render_docx.py Normal file
View file

@ -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 <output.docx> (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())

View file

@ -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,

149
src/utils/docSpec.js Normal file
View file

@ -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 };

View file

@ -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

87
test/doc-spec.test.js Normal file
View file

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

View file

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