Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
176 lines
7.7 KiB
JavaScript
176 lines
7.7 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 === 'question' && slide.question) {
|
|
var letters = 'ABCDEFGH';
|
|
blocks.push({ type: 'callout', text: 'Check yourself: ' + text(slide.question) });
|
|
if ((slide.options || []).length) {
|
|
blocks.push({ type: 'bullets', items: slide.options.map(function (o, i) { return { text: letters[i] + '. ' + text(o, 300), level: 0 }; }) });
|
|
}
|
|
if (slide.answer) blocks.push({ type: 'callout', text: 'Answer: ' + text(slide.answer) + (slide.explanation ? ' — ' + text(slide.explanation) : '') });
|
|
} 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 if (slide.type === 'flow' && (slide.steps || []).length) {
|
|
blocks.push({ type: 'bullets', items: slide.steps.map(function (step, i) {
|
|
return { text: (i + 1) + '. ' + text(step.text, 200) + (step.note ? ' — ' + text(step.note, 200) : ''), level: 0 };
|
|
}) });
|
|
} else if (slide.type === 'custom') {
|
|
// A diagram has no Word equivalent, so it becomes what it says: the words
|
|
// in reading order, its tables as tables, its figures as figures. Lossy,
|
|
// and better than the alternative of dropping the slide.
|
|
(slide.shapes || []).forEach(function (shape) {
|
|
if (shape.rows) {
|
|
blocks.push({ type: 'table', header: shape.header || [], rows: shape.rows });
|
|
} else if (shape.kind === 'image' && shape.image_job && byJob[shape.image_job]) {
|
|
blocks.push({ type: 'image', path: byJob[shape.image_job], caption: '' });
|
|
} else if ((shape.runs || []).length) {
|
|
var lines = shape.runs.map(function (r) { return { text: text(r.text, 1200), level: r.level || 0 }; });
|
|
blocks.push(shape.runs.some(function (r) { return r.bullet; })
|
|
? { type: 'bullets', items: lines }
|
|
: { type: 'para', text: lines.map(function (l) { return l.text; }).join(' ') });
|
|
}
|
|
});
|
|
} 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 };
|