pediatric-ai-scribe-v3/src/utils/docSpec.js
Daniel de13c8306b
All checks were successful
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Docker Build / Build Docker image (push) Successful in 9s
Forgejo Docker Build / End-to-end (browser) (push) Successful in 6s
feat: templates, not just palettes — colour title and section pages, heading bands, striped callouts, question slides with answers, key-point strips, page footers; Grand Rounds and Board Review join the catalogue
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 18:05:41 +02:00

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