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
149 lines
5.9 KiB
JavaScript
149 lines
5.9 KiB
JavaScript
// ============================================================
|
|
// 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 };
|