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
87 lines
4.5 KiB
JavaScript
87 lines
4.5 KiB
JavaScript
// ============================================================
|
|
// 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/);
|
|
});
|