// ============================================================ // 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 };