// coursebank — exam paper template // // This file is a template, not generated output. `coursebank export typst` // replaces only the marked regions below and leaves every other line exactly as // you wrote it, so this is where layout decisions belong. // // coursebank template dump write this file into templates/ // typst watch templates/exam.typ restyle it against the sample data // // The regions ship with sample values so that last command works before any // export has happened. Two markers are in play: // // // coursebank:begin meta a dictionary of course and form metadata // // coursebank:end meta // // coursebank:begin questions one #render-question(...) call per item // // coursebank:end questions // // An exported document keeps its markers, so exporting into a file you have since // restyled replaces the questions and leaves the styling alone. // // Note what is *not* in the payload for this variant: there is no `correct` field // on an option, because the render config for the paper withholds it. That is // deliberate. Do not switch `reveal` to `key` here in order to build a solutions // copy — export the `key` variant instead, or the day you forget an `if` is the // day the class gets the answers. #import "@preview/mitex:0.2.7": mi // ───────────────────────────────────────────────────────────────────────────── // Metadata // ───────────────────────────────────────────────────────────────────────────── // coursebank:begin meta #let cb-meta = ( course: (code: "COURSE 101", title: "Sample Course", term: "2026s"), assessment: ( id: "sample", title: "Sample assessment", date: "2026-01-01", minutes-allowed: 50.0, instructions: [Choose the single best answer unless a question says otherwise.], ), form: (id: "A", count: 1), totals: (questions: 1, scored: 1, bonus: 0, points: 1.5, bonus-points: 0.0), extra: (:), ) // coursebank:end meta // ───────────────────────────────────────────────────────────────────────────── // Settings // ───────────────────────────────────────────────────────────────────────────── // Anything under `extra` in templates/typst.yaml arrives here untouched, which is // how a course changes the look without editing this file at all. #let extra = cb-meta.at("extra", default: (:)) #let accent = rgb(extra.at("accent", default: "#1f4e79")) #let body-font = extra.at("font", default: "Roboto") #let body-size = eval(extra.at("font-size", default: "11pt")) #let paper = extra.at("paper", default: "us-letter") #let show-name-block = extra.at("name-block", default: true) #let show-points = extra.at("show-points", default: true) #let page-per-item = extra.at("page-per-item", default: false) #let show-bubbles = extra.at("show-bubbles", default: true) #let bubble-radius = eval(extra.at("bubble-radius", default: "0.5em")) #let scratch-space = eval(extra.at("scratch-space", default: "1.5em")) #let form-note = if cb-meta.form.at("count", default: 1) > 1 { " · Form " + cb-meta.form.id } else { "" } #set page( paper: paper, margin: 2cm, header: text(size: 0.85em)[ #cb-meta.course.code · #cb-meta.assessment.title#form-note ], footer: context text(size: 0.85em)[ Page #counter(page).display("1 of 1", both: true) ], ) #set text(font: body-font, size: body-size, lang: "en") #set par(justify: false, leading: 0.65em) // ───────────────────────────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────────────────────────── // Markup arrives as content when the render config says `content: content`, and as // a string when it says `content: str`. Accepting both means switching that // setting does not require editing the template. #let markup(v) = if type(v) == str { eval(v, mode: "markup") } else { v } // 1 point, 2 points, 1.5 points. #let fmt-points(p) = { let n = if p == calc.trunc(p) { str(calc.trunc(p)) } else { str(p) } n + if p == 1 { " point" } else { " points" } } #let points-tag(q) = { let p = q.at("points", default: none) let bonus = q.at("bonus", default: false) let label = if bonus and show-points and p != none { "bonus, " + fmt-points(p) } else if bonus { "bonus" } else if show-points and p != none { fmt-points(p) } else { none } if label != none { text(fill: luma(100), size: 0.9em)[(#label)] } } #let stimulus-block(s) = { block(stroke: 0.5pt + luma(180), inset: 8pt, radius: 3pt, width: 100%)[ #markup(s.body) ] let caption = s.at("caption", default: none) if caption != none { block(above: 0.3em)[#text(size: 0.85em, style: "italic")[#markup(caption)]] } } // An unfilled bubble a student marks by hand. `bubble-radius` is the same knob // the standalone answer sheet reads, so the two stay visually consistent if you // ever generate both. #let bubble() = circle(radius: bubble-radius, stroke: 0.5pt) // A small colored badge for a question's cognitive level, in the same visual // style as the tier badges on Exam 4. `level` counts up from 1; swap the order // of `level-colors` if your taxonomy numbers complexity the other way. Nothing // is drawn when a variant withholds `level` (`fields.level: false`), same as // any other optional field. #let level-colors = ( (bg: rgb("#D9E8EE"), fg: rgb("#264653")), (bg: rgb("#DCF6F3"), fg: rgb("#2a9d8f")), (bg: rgb("#FAF2DD"), fg: rgb("#D19F1F")), (bg: rgb("#FBE6E0"), fg: rgb("#E24E29")), (bg: rgb("#F0E9F5"), fg: rgb("#9967B6")), ) #let level-tag(q) = { let level = q.at("level", default: none) if level != none { let idx = calc.max(1, calc.min(level, level-colors.len())) - 1 let colors = level-colors.at(idx) let label = q.at("level-name", default: "Level " + str(level)) box(fill: colors.bg, radius: 3pt, inset: (x: 6pt, y: 3pt))[ #text(weight: "bold", size: 0.75em, fill: colors.fg)[#label] ] } } // ───────────────────────────────────────────────────────────────────────────── // The renderer // ───────────────────────────────────────────────────────────────────────────── // // One question, one function. Rename it if you like and set `question-fn` in // templates/typst.yaml to match. It takes a single dictionary so that turning a // field on or off in the config never changes this signature. #let render-question(q) = { // A stimulus shared by several items is printed with each of them. That repeats // material, but a student should never have to turn a page to find the passage a // question refers to. let s = q.at("stimulus", default: none) if s != none { stimulus-block(s) } // The number is the one recorded in the assessment record, not the position on // the page. Keep it that way: it is the join key to every grading export. let number-label = str(q.number) + "." block(breakable: false)[ #block(above: 1.2em, below: 0.3em)[ #grid( columns: (1fr, auto), align: (left + horizon, right + horizon), [*#number-label* #points-tag(q)], level-tag(q), ) ] #block(below: 1.5em)[#markup(q.stem)] #if q.at("multi-select", default: false) { block(below: 0.4em)[ #text(size: 0.9em, style: "italic")[Select all that apply.] ] } #block(inset: (left: 1.2em))[ #for opt in q.at("options", default: ()) { if show-bubbles { grid( columns: (1.6em, 1.4em, 1fr), gutter: 0.2em, align(top)[#v(-bubble-radius / 2.1) #bubble()], [#(opt.letter + ".")], [#markup(opt.text)], ) } else { grid( columns: (1.4em, 1fr), gutter: 0.2em, [#(opt.letter + ".")], [#markup(opt.text)], ) } v(0.15em) } ] #if scratch-space > 0pt { v(scratch-space) } ] if page-per-item { pagebreak(weak: true) } } // ───────────────────────────────────────────────────────────────────────────── // The page // ───────────────────────────────────────────────────────────────────────────── #set page(paper: paper, margin: 2cm) #set text(font: body-font, size: body-size, lang: "en") #set par(justify: false, leading: 0.7em) // ── Cover page: no header or footer, so it reads as its own sheet. ── #set page(header: none, footer: none) #v(4em) #align(center)[ #text(weight: "bold", size: 18pt, fill: accent)[ #cb-meta.course.code — #cb-meta.course.title ]\ #text(size: 16pt)[#cb-meta.assessment.title#form-note]\ #text(size: 14pt)[ #cb-meta.assessment.at("date", default: "") #{ let m = cb-meta.assessment.at("minutes-allowed", default: none) if m != none { " · " + str(int(calc.round(m))) + " minutes" } } ]\ #{ let p = cb-meta.totals.at("points", default: none) if p != none { text(size: 12pt)[#fmt-points(p)] } } ] #v(1.5em) #{ let instructions = cb-meta.assessment.at("instructions", default: none) if instructions != none [ Please read the following instructions carefully before beginning your assessment. #v(1em) #markup(instructions) ] } #if show-name-block [ #v(1.5em) I agree to follow the above instructions. I affirm that all work on this assessment will be my own and that I will not give or receive any unauthorized assistance. To have your assessment graded, you must write your name, sign, and provide your student ID below. #v(1em) #grid( columns: (50%, 50%), rows: 6em, [ #v(1em) #line(length: 18em) *Name* ], [ #v(1em) #line(length: 18em) *Signature* ], ) #line(length: 8em) *Student ID* ] #pagebreak() // Intentionally blank. Pull this sheet and the cover above as one unit if you // need the cover gone — nothing on the back of either one is a question. #pagebreak() // ── The exam itself: header and footer turn on here. ── #set page( header: text(size: 0.85em)[ #cb-meta.course.code · #cb-meta.assessment.title#form-note ], footer: context text(size: 0.85em)[ Page #counter(page).display("1 of 1", both: true) ], ) // Restart the printed page count here too, so the footer reads "Page 1 of N" // for the exam itself rather than counting the cover and the blank page. #counter(page).update(1) // coursebank:begin questions #render-question(( number: 1, points: 1.5, bonus: false, multi-select: false, stem: [This sample question is replaced on the next export.], options: ( (letter: "A", position: 1, text: [The first option.]), (letter: "B", position: 2, text: [The second option.]), ), )) // coursebank:end questions