diff --git a/assets/deck-themes.json b/assets/deck-themes.json index 8e0a7f80..086f5a4d 100644 --- a/assets/deck-themes.json +++ b/assets/deck-themes.json @@ -4,7 +4,7 @@ { "id": "clinical-blue", "name": "Clinical Blue", - "description": "The default. Calm, high-contrast, reads well on a projector.", + "description": "The default. Calm, high-contrast, reads well on a projector. Key-point strips under slides, question panels for check-yourself moments.", "accent": "2563EB", "ink": "1F2937", "muted": "4B5563", @@ -13,12 +13,21 @@ "font": "Calibri", "tint": "EFF6FF", "tint_alt": "FEF3C7", - "accent_alt": "D97706" + "accent_alt": "D97706", + "style": { + "title": "band", + "section": "quiet", + "heading": "rule", + "callout": "card", + "question": "panel", + "footer": false, + "takeaway": true + } }, { "id": "teaching-amber", "name": "Teaching Amber", - "description": "Warmer and less clinical. Good for sessions with parents or students.", + "description": "Warmer and less clinical, for sessions with parents or students. Split title page, numbered section bands, striped callouts.", "accent": "D97706", "ink": "1C1917", "muted": "57534E", @@ -27,12 +36,21 @@ "font": "Calibri", "tint": "FEF3C7", "tint_alt": "EFF6FF", - "accent_alt": "2563EB" + "accent_alt": "2563EB", + "style": { + "title": "split", + "section": "band", + "heading": "bar", + "callout": "stripe", + "question": "panel", + "footer": false, + "takeaway": true + } }, { "id": "ward-teal", "name": "Ward Teal", - "description": "Quieter than blue, still clearly clinical.", + "description": "Quieter than blue, still clearly clinical. Page numbers on every slide; questions get a spotlight slide of their own.", "accent": "0F766E", "ink": "134E4A", "muted": "4B5563", @@ -41,12 +59,21 @@ "font": "Calibri", "tint": "CCFBF1", "tint_alt": "FEF3C7", - "accent_alt": "D97706" + "accent_alt": "D97706", + "style": { + "title": "band", + "section": "quiet", + "heading": "rule", + "callout": "card", + "question": "spotlight", + "footer": true, + "takeaway": true + } }, { "id": "slate", "name": "Slate", - "description": "Almost monochrome. Lets figures and tables carry the colour.", + "description": "Almost monochrome, so figures and tables carry the colour. Striped callouts, page numbers, no key-point strips.", "accent": "475569", "ink": "0F172A", "muted": "64748B", @@ -55,12 +82,21 @@ "font": "Calibri", "tint": "F1F5F9", "tint_alt": "E2E8F0", - "accent_alt": "64748B" + "accent_alt": "64748B", + "style": { + "title": "band", + "section": "quiet", + "heading": "rule", + "callout": "stripe", + "question": "panel", + "footer": true, + "takeaway": false + } }, { "id": "high-contrast", "name": "High Contrast", - "description": "For a bright room or a poor projector. Heavier ink, stronger rules.", + "description": "For a bright room or a poor projector: full-colour title and section pages, headings on bands, heavier ink.", "accent": "B91C1C", "ink": "000000", "muted": "27272A", @@ -69,7 +105,62 @@ "font": "Calibri", "tint": "FEE2E2", "tint_alt": "F3F4F6", - "accent_alt": "111827" + "accent_alt": "111827", + "style": { + "title": "full", + "section": "band", + "heading": "band", + "callout": "card", + "question": "spotlight", + "footer": true, + "takeaway": true + } + }, + { + "id": "grand-rounds", + "name": "Grand Rounds", + "description": "For a lecture theatre: deep navy title and section pages with big numbers, page numbers, striped callouts, key points under each slide.", + "ink": "0F172A", + "muted": "475569", + "accent": "1E3A8A", + "accent_alt": "B45309", + "rule": "CBD5E1", + "paper": "FFFFFF", + "tint": "E0E7FF", + "tint_alt": "FEF3C7", + "font": "Calibri", + "style": { + "title": "full", + "section": "band", + "heading": "bar", + "callout": "stripe", + "question": "spotlight", + "footer": true, + "takeaway": true + } + }, + { + "id": "board-review", + "name": "Board Review", + "description": "Built around checking yourself: question panels with lettered options and an answer slide, a key point under every slide, headings on bands.", + "ink": "1F2937", + "muted": "6B7280", + "accent": "7C3AED", + "accent_alt": "059669", + "rule": "E5E7EB", + "paper": "FFFFFF", + "tint": "F3E8FF", + "tint_alt": "D1FAE5", + "font": "Calibri", + "style": { + "title": "split", + "section": "band", + "heading": "band", + "callout": "card", + "question": "panel", + "footer": true, + "takeaway": true + } } ] -} +} \ No newline at end of file diff --git a/docs/my-resources.md b/docs/my-resources.md index 0189d46c..c0be160f 100644 --- a/docs/my-resources.md +++ b/docs/my-resources.md @@ -172,6 +172,34 @@ falls back to asking for markdown; a malformed slide degrades to bullets; a comparison with one column is not a comparison; JSON wrapped in fences or a covering sentence is read rather than refused. +## Templates + +A template is a theme plus a layout style, all in `assets/deck-themes.json` +and all drawn by `scripts/render_pptx.py` — nothing about the look depends on +the model, which only ever writes the slide content. Seven ship: + +| Template | What it does beyond its colours | +|---|---| +| Clinical Blue | Plain title page, rule under headings, amber callout card, key-point strips, question panels | +| Teaching Amber | Split title page, numbered colour section pages, accent bar beside headings, striped callouts | +| Ward Teal | Page numbers on every slide, questions on a dark spotlight slide | +| Slate | Monochrome; striped callouts, page numbers, no key-point strips | +| High Contrast | Full-colour title and section pages, headings on bands, spotlight questions | +| Grand Rounds | Deep navy title and numbered section pages, striped callouts, key points, page numbers | +| Board Review | Built around checking yourself: question panels, an answer slide after each, a key point under every slide | + +The `style` block of a theme has seven keys, each with the value the renderer +always had as its default: `title` (`band` / `full` / `split`), `section` +(`quiet` / `band`), `heading` (`rule` / `bar` / `band`), `callout` (`card` / +`stripe`), `question` (`panel` / `spotlight`), `footer` and `takeaway` +(booleans). Two things the content can carry for the templates to draw: a +`question` slide (`question`, lettered `options`, an `answer` and its +`explanation`, drawn as a "?" slide followed by an answer slide) and a +`takeaway` line on any content slide, drawn as a key-point strip under the +body where the template allows it and carried into the Word and PDF exports +as a bold closing line either way. The sample deck under every theme shows +all of it. + ## Illustrations `resourceImages.js`, **not** the shared `imageTool.dispatch` — that one permits diff --git a/scripts/__pycache__/render_pptx.cpython-313.pyc b/scripts/__pycache__/render_pptx.cpython-313.pyc index 06a570e2..9f804af0 100644 Binary files a/scripts/__pycache__/render_pptx.cpython-313.pyc and b/scripts/__pycache__/render_pptx.cpython-313.pyc differ diff --git a/scripts/render_pptx.py b/scripts/render_pptx.py index aed3f814..30de91ef 100644 --- a/scripts/render_pptx.py +++ b/scripts/render_pptx.py @@ -96,6 +96,16 @@ def load_themes(): return [] +# What a template does beyond its colours. Every key has the value the +# renderer always had, so a theme without a style block looks as it always did. +STYLE = {"title": "band", "section": "quiet", "heading": "rule", "callout": "card", + "question": "panel", "footer": False, "takeaway": False} +DECK_TITLE = "" +SECTION_NO = 0 +FOOTER_H = Emu(320040) # 0.35" +TAKEAWAY_H = Emu(640080) # 0.7" + + def apply_theme(theme_id): """Rebind the palette. An unknown or missing id leaves the default in place, because a deck rendering in the wrong colours beats a deck not rendering.""" @@ -119,9 +129,56 @@ def apply_theme(theme_id): except Exception: return None FONT = theme.get("font") or FONT + style = theme.get("style") or {} + for key in list(STYLE): + if key in style: + STYLE[key] = style[key] return theme return None + +def _rect(slide, left, top, width, height, fill, shape=1): + box = slide.shapes.add_shape(shape, left, top, width, height) + box.fill.solid() + box.fill.fore_color.rgb = fill + box.line.fill.background() + box.shadow.inherit = False + box.text_frame.text = "" + return box + + +def _footer(slide, number, total): + """Deck title left, page number right, in the margin below the body.""" + if not STYLE.get("footer") or number <= 1: + return + top = SLIDE_H - FOOTER_H + left = _textbox(slide, MARGIN, top, Emu(int(BODY_W * 0.7)), FOOTER_H) + left.vertical_anchor = MSO_ANCHOR.MIDDLE + _run(left.paragraphs[0], DECK_TITLE[:90], 10, color=MUTED) + right = _textbox(slide, MARGIN + Emu(int(BODY_W * 0.7)), top, Emu(int(BODY_W * 0.3)), FOOTER_H) + right.vertical_anchor = MSO_ANCHOR.MIDDLE + para = right.paragraphs[0] + para.alignment = PP_ALIGN.RIGHT + _run(para, "%d / %d" % (number, total), 10, color=MUTED) + + +def _takeaway(slide, text): + """One line the audience should leave with, on a strip under the body. + Returns how much body height it took, so the builder can shrink its frame.""" + text = (text or "").strip() + if not text or not STYLE.get("takeaway"): + return Emu(0) + top = SLIDE_H - MARGIN - TAKEAWAY_H + _rect(slide, MARGIN, top, BODY_W, TAKEAWAY_H, TINT, shape=5) + _rect(slide, MARGIN, top, Emu(91440), TAKEAWAY_H, ACCENT) + frame = _textbox(slide, MARGIN + Emu(228600), top, BODY_W - Emu(457200), TAKEAWAY_H) + frame.vertical_anchor = MSO_ANCHOR.MIDDLE + para = frame.paragraphs[0] + _run(para, "Key point ", 12, bold=True, color=ACCENT) + for chunk, bold in _split_bold(text): + _run(para, chunk, 16 if len(text) <= 110 else 14, bold=bold, color=INK) + return TAKEAWAY_H + Emu(182880) + # Text is sized to fit rather than left to the renderer's autofit, which only # some viewers honour and which LibreOffice ignores entirely when converting to # PDF — the reason slides were being cut off mid-sentence. @@ -156,13 +213,21 @@ def _run(paragraph, text, size, bold=False, color=INK, italic=False): def _heading(slide, text, accent_rule=True): - frame = _textbox(slide, MARGIN, HEADING_TOP, BODY_W, HEADING_H) + style = STYLE.get("heading") or "rule" + left = MARGIN + if style == "band": + _rect(slide, Emu(0), Emu(0), SLIDE_W, HEADING_TOP + HEADING_H + Emu(91440), TINT) + _rect(slide, Emu(0), Emu(0), Emu(137160), HEADING_TOP + HEADING_H + Emu(91440), ACCENT) + elif style == "bar": + _rect(slide, MARGIN, HEADING_TOP + Emu(182880), Emu(91440), HEADING_H - Emu(182880), ACCENT) + left = MARGIN + Emu(274320) + frame = _textbox(slide, left, HEADING_TOP, BODY_W - (left - MARGIN), HEADING_H) frame.vertical_anchor = MSO_ANCHOR.BOTTOM para = frame.paragraphs[0] # Long headings shrink rather than wrapping into the body. size = 34 if len(text) <= 52 else (30 if len(text) <= 74 else 26) _run(para, text, size, bold=True) - if accent_rule: + if accent_rule and style == "rule": bar = slide.shapes.add_shape(1, MARGIN, HEADING_TOP + HEADING_H + Emu(45720), Emu(548640), Emu(45720)) bar.fill.solid() bar.fill.fore_color.rgb = ACCENT @@ -251,10 +316,42 @@ def _notes(slide, text): def slide_title(prs, spec): slide = _blank(prs) + style = STYLE.get("title") or "band" + heading = spec.get("heading") or "Untitled" + if style == "full": + _rect(slide, Emu(0), Emu(0), SLIDE_W, SLIDE_H, ACCENT) + _rect(slide, MARGIN, Emu(1874520), Emu(1097280), Emu(54864), PAPER) + frame = _textbox(slide, MARGIN, Emu(2057400), BODY_W, Emu(2286000)) + frame.vertical_anchor = MSO_ANCHOR.MIDDLE + para = frame.paragraphs[0] + _run(para, heading, 44 if len(heading) <= 60 else 36, bold=True, color=PAPER) + for line in [spec.get("subtitle"), spec.get("date")]: + if line: + sub = frame.add_paragraph() + sub.space_before = Pt(10) + _run(sub, line, 18, color=PAPER) + return slide + if style == "split": + panel_w = Emu(int(SLIDE_W * 0.42)) + _rect(slide, Emu(0), Emu(0), panel_w, SLIDE_H, ACCENT) + frame = _textbox(slide, MARGIN, Emu(1600200), panel_w - MARGIN - Emu(274320), Emu(3657600)) + frame.vertical_anchor = MSO_ANCHOR.MIDDLE + _run(frame.paragraphs[0], heading, 36 if len(heading) <= 60 else 30, bold=True, color=PAPER) + right = _textbox(slide, panel_w + Emu(548640), Emu(2514600), SLIDE_W - panel_w - Emu(548640) - MARGIN, Emu(1828800)) + right.vertical_anchor = MSO_ANCHOR.MIDDLE + first = True + for line in [spec.get("subtitle"), spec.get("date")]: + if not line: + continue + para = right.paragraphs[0] if first else right.add_paragraph() + if not first: + para.space_before = Pt(10) + _run(para, line, 20 if first else 16, color=INK if first else MUTED) + first = False + return slide frame = _textbox(slide, MARGIN, Emu(2057400), BODY_W, Emu(1828800)) frame.vertical_anchor = MSO_ANCHOR.MIDDLE para = frame.paragraphs[0] - heading = spec.get("heading") or "Untitled" _run(para, heading, 44 if len(heading) <= 60 else 36, bold=True) for line in [spec.get("subtitle"), spec.get("date")]: if not line: @@ -271,7 +368,19 @@ def slide_title(prs, spec): def slide_section(prs, spec): + global SECTION_NO + SECTION_NO += 1 slide = _blank(prs) + if (STYLE.get("section") or "quiet") == "band": + _rect(slide, Emu(0), Emu(0), SLIDE_W, SLIDE_H, ACCENT) + number = _textbox(slide, MARGIN, Emu(1371600), BODY_W, Emu(1600200)) + number.vertical_anchor = MSO_ANCHOR.BOTTOM + _run(number.paragraphs[0], "%02d" % SECTION_NO, 72, bold=True, color=PAPER) + _rect(slide, MARGIN, Emu(3017520), Emu(1097280), Emu(54864), PAPER) + frame = _textbox(slide, MARGIN, Emu(3200400), BODY_W, Emu(1828800)) + frame.vertical_anchor = MSO_ANCHOR.TOP + _run(frame.paragraphs[0], spec.get("heading") or "", 36, bold=True, color=PAPER) + return slide frame = _textbox(slide, MARGIN, Emu(2743200), BODY_W, Emu(1371600)) frame.vertical_anchor = MSO_ANCHOR.MIDDLE para = frame.paragraphs[0] @@ -283,8 +392,9 @@ def slide_bullets(prs, spec): slide = _blank(prs) _heading(slide, spec.get("heading") or "") items = spec.get("bullets") or [] - size = _fit_size(items) - frame = _textbox(slide, MARGIN, BODY_TOP, BODY_W, BODY_H) + taken = _takeaway(slide, spec.get("takeaway")) + size = _fit_size(items, available_h_emu=BODY_H - taken) + frame = _textbox(slide, MARGIN, BODY_TOP, BODY_W, BODY_H - taken) _bullets(frame, items, size) _notes(slide, spec.get("notes")) return slide @@ -435,6 +545,25 @@ def slide_callout(prs, spec): """One thing worth stopping on: a red flag, a dose, a rule of thumb.""" slide = _blank(prs) _heading(slide, spec.get("heading") or "") + text = (spec.get("text") or "").strip() + size = 28 if len(text) <= 90 else (22 if len(text) <= 180 else 18) + if (STYLE.get("callout") or "card") == "stripe": + # A white card with a thick accent stripe and a mark in the margin. + card_h = Emu(int(BODY_H * 0.62)) + _rect(slide, MARGIN, BODY_TOP, BODY_W, card_h, TINT, shape=5) + _rect(slide, MARGIN, BODY_TOP, Emu(182880), card_h, ACCENT_ALT) + mark = _rect(slide, MARGIN + Emu(457200), BODY_TOP + Emu(int(card_h / 2)) - Emu(320040), Emu(640080), Emu(640080), ACCENT_ALT, shape=9) + mp = mark.text_frame.paragraphs[0] + mp.alignment = PP_ALIGN.CENTER + _run(mp, "!", 28, bold=True, color=PAPER) + frame = _textbox(slide, MARGIN + Emu(1371600), BODY_TOP + Emu(365760), + Emu(int(BODY_W) - 1371600 - 457200), card_h - Emu(731520)) + frame.vertical_anchor = MSO_ANCHOR.MIDDLE + para = frame.paragraphs[0] + for chunk, bold in _split_bold(text): + _run(para, chunk, size, bold=True, color=INK) + _notes(slide, spec.get("notes")) + return slide card = slide.shapes.add_shape(5, MARGIN, BODY_TOP, BODY_W, Emu(int(BODY_H * 0.62))) card.fill.solid() card.fill.fore_color.rgb = TINT_ALT @@ -443,19 +572,74 @@ def slide_callout(prs, spec): card.shadow.inherit = False card.text_frame.text = "" - text = (spec.get("text") or "").strip() frame = _textbox(slide, MARGIN + Emu(457200), BODY_TOP + Emu(365760), Emu(int(BODY_W) - 914400), Emu(int(BODY_H * 0.62) - 731520)) frame.vertical_anchor = MSO_ANCHOR.MIDDLE para = frame.paragraphs[0] para.alignment = PP_ALIGN.CENTER - size = 28 if len(text) <= 90 else (22 if len(text) <= 180 else 18) for chunk, bold in _split_bold(text): _run(para, chunk, size, bold=bold or True, color=RGBColor(0x78, 0x35, 0x0F)) _notes(slide, spec.get("notes")) return slide +def slide_question(prs, spec): + """A check-yourself moment: the question with its options, a big mark to say + so, and — when an answer was given — the answer on the slide after, so the + room can commit before it is shown.""" + slides = [] + question = (spec.get("question") or "").strip() + options = [o for o in (spec.get("options") or []) if o] + style = STYLE.get("question") or "panel" + slide = _blank(prs) + slides.append(slide) + if style == "spotlight": + _rect(slide, Emu(0), Emu(0), SLIDE_W, SLIDE_H, INK) + fg, muted, panel = PAPER, RULE, ACCENT + _heading_color = PAPER + else: + fg, muted, panel = INK, MUTED, TINT + if style == "spotlight": + head = _textbox(slide, MARGIN, HEADING_TOP, BODY_W, HEADING_H) + head.vertical_anchor = MSO_ANCHOR.BOTTOM + _run(head.paragraphs[0], spec.get("heading") or "Check yourself", 30, bold=True, color=PAPER) + else: + _heading(slide, spec.get("heading") or "Check yourself") + mark_w = Emu(1828800) + mark = _rect(slide, MARGIN, BODY_TOP, mark_w, mark_w, panel, shape=9) + mp = mark.text_frame.paragraphs[0] + mp.alignment = PP_ALIGN.CENTER + mark.text_frame.vertical_anchor = MSO_ANCHOR.MIDDLE + _run(mp, "?", 80, bold=True, color=PAPER if style == "spotlight" else ACCENT) + frame = _textbox(slide, MARGIN + mark_w + Emu(457200), BODY_TOP, BODY_W - mark_w - Emu(457200), BODY_H) + para = frame.paragraphs[0] + _run(para, question, 26 if len(question) <= 120 else (22 if len(question) <= 220 else 18), bold=True, color=fg) + letters = "ABCDEFGH" + for i, option in enumerate(options[:6]): + p = frame.add_paragraph() + p.space_before = Pt(10) + _run(p, letters[i] + " ", 18, bold=True, color=ACCENT if style != "spotlight" else RULE) + _run(p, option, 18, color=fg) + _notes(slide, spec.get("notes")) + + answer = (spec.get("answer") or "").strip() + if answer: + reveal = _blank(prs) + slides.append(reveal) + _heading(reveal, "Answer") + _rect(reveal, MARGIN, BODY_TOP, BODY_W, Emu(int(BODY_H * 0.45)), TINT_ALT, shape=5) + _rect(reveal, MARGIN, BODY_TOP, Emu(182880), Emu(int(BODY_H * 0.45)), ACCENT_ALT) + box = _textbox(reveal, MARGIN + Emu(457200), BODY_TOP + Emu(228600), BODY_W - Emu(914400), Emu(int(BODY_H * 0.45)) - Emu(457200)) + box.vertical_anchor = MSO_ANCHOR.MIDDLE + _run(box.paragraphs[0], answer, 24 if len(answer) <= 120 else 18, bold=True, color=INK) + why = (spec.get("explanation") or "").strip() + if why: + wf = _textbox(reveal, MARGIN, BODY_TOP + Emu(int(BODY_H * 0.45)) + Emu(274320), BODY_W, Emu(int(BODY_H * 0.5))) + for chunk, bold in _split_bold(why): + _run(wf.paragraphs[0], chunk, 18, bold=bold, color=INK) + return slides + + def slide_figure(prs, spec): """A figure beside its text, rather than alone on a slide of its own.""" slide = _blank(prs) @@ -720,6 +904,7 @@ BUILDERS = { "image": slide_image, "compare": slide_compare, "callout": slide_callout, + "question": slide_question, "figure": slide_figure, "flow": slide_flow, "custom": slide_custom, @@ -742,8 +927,16 @@ def main(): print("spec contains no slides", file=sys.stderr) return 3 + global DECK_TITLE, SECTION_NO + DECK_TITLE = str(spec.get("title") or "") + SECTION_NO = 0 + built = [] for item in slides: - BUILDERS.get(item.get("type") or "bullets", slide_bullets)(prs, item) + result = BUILDERS.get(item.get("type") or "bullets", slide_bullets)(prs, item) + built.extend(result if isinstance(result, list) else [result]) + for number, slide in enumerate(built, start=1): + if slide is not None: + _footer(slide, number, len(built)) prs.save(sys.argv[1]) return 0 diff --git a/src/utils/deckSample.js b/src/utils/deckSample.js index 69678654..149ca6c2 100644 --- a/src/utils/deckSample.js +++ b/src/utils/deckSample.js @@ -54,6 +54,7 @@ function build(theme) { ['A second sub-point, to show the spacing', 1], 'A closing point to fill the slide out' ]), + takeaway: 'A key point: one line the audience leaves with, on a strip under the body.', notes: 'Speaker notes look like this. They do not appear on the slide.' }, { type: 'compare', heading: 'Two labelled columns', @@ -78,6 +79,12 @@ function build(theme) { { type: 'callout', heading: 'A callout', text: 'One sentence on a tinted card, for something worth stopping on.' }, + { type: 'question', heading: 'Check yourself', + question: 'A question for the room, with lettered options; the answer is on the next slide.', + options: ['The first option', 'The second option', 'The third option', 'The fourth option'], + answer: 'The second option', + explanation: 'One line on why, so the reveal teaches something.' }, + { type: 'figure', heading: 'A figure beside its text', image_job: FIGURE_JOB, bullets: bullets([ diff --git a/src/utils/deckSchema.js b/src/utils/deckSchema.js index 733cbca9..c631a878 100644 --- a/src/utils/deckSchema.js +++ b/src/utils/deckSchema.js @@ -16,7 +16,7 @@ var slideShapes = require('./slideShapes'); -var VALID = ['title', 'section', 'bullets', 'two', 'compare', 'table', 'callout', 'figure', 'image', 'flow', 'custom']; +var VALID = ['title', 'section', 'bullets', 'two', 'compare', 'table', 'callout', 'question', 'figure', 'image', 'flow', 'custom']; // Given to the model verbatim. Written as prose rather than a JSON Schema dump // because the failure to avoid is a model that produces valid JSON describing a @@ -44,6 +44,14 @@ function instructions(slideCount, figureCount) { ' One sentence on a tinted card. Use it for a red flag, a dose, a rule of', ' thumb — something worth stopping on. At most one or two per deck.', '', + '{"type":"question","heading":"Check yourself","question":"...","options":["...","...","...","..."],"answer":"...","explanation":"..."}', + ' A question for the room, drawn with a question mark and lettered options;', + ' the answer and its one-line explanation go on the slide after, so people', + ' commit first. One or two per deck, at the end of a part, is about right.', + '', + 'Any bullets, table, compare, flow or figure slide may carry "takeaway": one', + 'sentence the audience should leave with; it is drawn as a key-point strip.', + '', '{"type":"figure","heading":"...","bullets":[...],"image_prompt":"..."}', ' Bullets on the left, an illustration on the right. image_prompt describes the', ' figure to draw: schematic or anatomical teaching artwork, never a real patient.', @@ -155,6 +163,13 @@ function normalise(raw, gaps) { } else if (type === 'callout') { out.text = text(slide.text, 400); if (!out.text) return; + } else if (type === 'question') { + out.question = text(slide.question || slide.text, 400); + out.options = (Array.isArray(slide.options) ? slide.options : []).slice(0, 6) + .map(function (o) { return text(typeof o === 'string' ? o : (o && o.text), 160); }).filter(Boolean); + out.answer = text(slide.answer, 300); + out.explanation = text(slide.explanation, 500); + if (!out.question) return; } else if (type === 'title') { out.subtitle = text(slide.subtitle, 200); out.date = text(slide.date, 60); @@ -173,6 +188,11 @@ function normalise(raw, gaps) { out.bullets = bullets(slide.bullets); if (type === 'bullets' && !out.bullets.length && !out.heading) return; } + // One line to leave with. Templates that show key points draw it as a + // strip under the body; the others carry it into the document export. + if (['bullets', 'two', 'table', 'flow', 'figure', 'compare'].indexOf(out.type) !== -1 && slide.takeaway) { + out.takeaway = text(slide.takeaway, 160); + } // An image_prompt on a slide that does not carry pictures used to be dropped // in silence, so a model that asked for a figure the wrong way got none and @@ -246,6 +266,11 @@ function toMarkdown(deck) { } else if (slide.type === 'callout') { out.push('**' + slide.text + '**'); out.push(''); + } else if (slide.type === 'question') { + out.push('**Check yourself:** ' + slide.question); + (slide.options || []).forEach(function (o, i) { out.push('- ' + 'ABCDEFGH'[i] + '. ' + o); }); + if (slide.answer) out.push('\n**Answer:** ' + slide.answer + (slide.explanation ? ' — ' + slide.explanation : '')); + out.push(''); } else if (slide.type === 'custom') { // Lossy on purpose: a diagram has no markdown. Its words survive so Word // export and a text edit still have something to work with. @@ -266,6 +291,7 @@ function toMarkdown(deck) { out.push(''); } if (slide.notes) { out.push('> ' + slide.notes.replace(/\n/g, ' ')); out.push(''); } + if (slide.takeaway) { out.push('**Key point:** ' + slide.takeaway); out.push(''); } }); return out.join('\n').replace(/\n{3,}/g, '\n\n').trim() + '\n'; } diff --git a/src/utils/docSpec.js b/src/utils/docSpec.js index 78972e05..8db7f5f1 100644 --- a/src/utils/docSpec.js +++ b/src/utils/docSpec.js @@ -27,6 +27,13 @@ function fromDeck(deck, images) { 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) { diff --git a/test/deck-theme-sample.test.js b/test/deck-theme-sample.test.js index 40716c04..ed59fb96 100644 --- a/test/deck-theme-sample.test.js +++ b/test/deck-theme-sample.test.js @@ -14,8 +14,7 @@ const deckSchema = require('../src/utils/deckSchema'); // The vocabulary the renderer can draw, from the schema itself, so a layout // added later fails this until the sample shows it too. -const SCHEMA_TYPES = ['title', 'section', 'bullets', 'two', 'compare', 'table', - 'callout', 'figure', 'image', 'custom', 'flow']; +const SCHEMA_TYPES = ['title', 'section', 'bullets', 'two', 'compare', 'table', 'callout', 'question', 'figure', 'image', 'custom', 'flow']; test('the sample shows every layout the renderer can draw', () => { const deck = deckSample.build({ id: 'clinical-blue', name: 'Clinical Blue' });