A pathway or an algorithm had no slide of its own and came out as bullets. "flow" is a slide of two to eight steps, each a short phrase with an optional note; the exporter lays it out — across for up to four, down for more — as rounded boxes joined by arrows in the shape vocabulary the renderer already draws, so the model is never asked for coordinates and Python gains one small builder. Word gets the steps as a numbered list. The theme sample shows one, so every template demonstrates it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
169 lines
7.1 KiB
JavaScript
169 lines
7.1 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 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 };
|