324 lines
12 KiB
Typst
324 lines
12 KiB
Typst
// 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
|