@@ -0,0 +1,70 @@
|
||||
// coursebank — answer sheet template
|
||||
//
|
||||
// Bubbles are generated from each item's own option count, so an item with three
|
||||
// options gets three bubbles rather than the five the rest of the form has. That is
|
||||
// the whole reason this document is generated at all: a hand-drawn sheet drifts out
|
||||
// of step with the paper, and the drift is invisible until it is scanned.
|
||||
//
|
||||
// // coursebank:begin data
|
||||
// // coursebank:end data
|
||||
//
|
||||
// If you scan these, set `bubble-radius` and the column count in
|
||||
// templates/typst.yaml under `extra` rather than editing the geometry here, and
|
||||
// check one printed page against your scanner before running a class through it.
|
||||
|
||||
// coursebank:begin data
|
||||
#let cb-data = (
|
||||
course: (code: "COURSE 101", title: "Sample Course", term: "2026s"),
|
||||
assessment: (id: "sample", title: "Sample assessment", date: "2026-01-01"),
|
||||
form: (id: "A", count: 1),
|
||||
totals: (questions: 1, scored: 1, bonus: 0, points: 1.5, bonus-points: 0.0),
|
||||
extra: (:),
|
||||
questions: (
|
||||
(
|
||||
number: 1,
|
||||
bonus: false,
|
||||
stem: [Replaced on the next export.],
|
||||
options: (
|
||||
(letter: "A", position: 1, text: [First.]),
|
||||
(letter: "B", position: 2, text: [Second.]),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
// coursebank:end data
|
||||
|
||||
#let extra = cb-data.at("extra", default: (:))
|
||||
#let bubble-radius = eval(extra.at("bubble-radius", default: "0.42em"))
|
||||
#let columns-count = extra.at("columns", default: 2)
|
||||
|
||||
#set page(paper: extra.at("paper", default: "us-letter"), margin: 1.5cm)
|
||||
#set text(size: 10pt)
|
||||
|
||||
#let bubble(letter) = circle(radius: bubble-radius, stroke: 0.5pt)[
|
||||
#align(center + horizon)[#text(size: 0.7em)[#letter]]
|
||||
]
|
||||
|
||||
= #cb-data.assessment.title — answer sheet (form #cb-data.form.id)
|
||||
|
||||
#grid(
|
||||
columns: (auto, 1fr, auto, 1fr),
|
||||
gutter: 0.6em,
|
||||
[*Name*], box(width: 100%, repeat[.]), [*Student ID*], box(width: 100%, repeat[.]),
|
||||
)
|
||||
|
||||
#v(1em)
|
||||
|
||||
#columns(columns-count)[
|
||||
#for q in cb-data.questions {
|
||||
block(below: 0.45em)[
|
||||
#box(width: 2em)[#(str(q.number) + ".")]
|
||||
#for opt in q.at("options", default: ()) {
|
||||
bubble(opt.letter)
|
||||
h(0.25em)
|
||||
}
|
||||
#if q.at("bonus", default: false) {
|
||||
text(size: 0.7em, fill: luma(120))[ bonus]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,222 @@
|
||||
// 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.
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 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: "Libertinus Serif")
|
||||
#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 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)[
|
||||
#counter(page).display("Page 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)]]
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 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.
|
||||
//
|
||||
// Built in code rather than written as `*#q.number.*` because a field access
|
||||
// followed by a literal period reads as the start of another field access.
|
||||
let number-label = str(q.number) + "."
|
||||
|
||||
block(above: 1.2em, below: 0.5em)[
|
||||
*#number-label* #points-tag(q) #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: ()) {
|
||||
grid(
|
||||
columns: (1.4em, 1fr),
|
||||
gutter: 0.2em,
|
||||
[#(opt.letter + ".")], [#markup(opt.text)],
|
||||
)
|
||||
v(0.15em)
|
||||
}
|
||||
]
|
||||
|
||||
if page-per-item { pagebreak(weak: true) }
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// The page
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#align(center)[
|
||||
#text(size: 1.4em, weight: "bold", fill: accent)[#cb-meta.assessment.title]\
|
||||
#text(size: 0.95em)[
|
||||
#cb-meta.course.code — #cb-meta.course.title · #cb-meta.assessment.term
|
||||
]\
|
||||
#text(size: 0.9em)[
|
||||
#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 { " · " + fmt-points(p) }
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
#if show-name-block {
|
||||
block(above: 1em, below: 1.5em)[
|
||||
#grid(
|
||||
columns: (auto, 1fr, auto, 1fr),
|
||||
gutter: 0.6em,
|
||||
[*Name*], box(width: 100%, repeat[.]), [*Student ID*], box(width: 100%, repeat[.]),
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
#{
|
||||
let instructions = cb-meta.assessment.at("instructions", default: none)
|
||||
if instructions != none {
|
||||
block(fill: luma(245), inset: 8pt, radius: 3pt, width: 100%)[
|
||||
#markup(instructions)
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -0,0 +1,156 @@
|
||||
// coursebank — answer key template
|
||||
//
|
||||
// This one uses the `data` slot rather than `questions`: it gets the whole payload
|
||||
// as a single dictionary and loops over it here, which suits a table better than an
|
||||
// unrolled sequence of calls. Both slots are always available; use whichever fits
|
||||
// what you are building.
|
||||
//
|
||||
// // coursebank:begin data
|
||||
// // coursebank:end data
|
||||
//
|
||||
// The key's render config sets `reveal: everything`, so options here carry
|
||||
// `correct`, `credit`, and the authored rationale. That is the opposite of the
|
||||
// paper's config, and it is why these are two templates rather than one with a
|
||||
// flag.
|
||||
|
||||
// coursebank:begin data
|
||||
#let cb-data = (
|
||||
course: (code: "COURSE 101", title: "Sample Course", term: "2026s"),
|
||||
assessment: (id: "sample", title: "Sample assessment", date: "2026-01-01"),
|
||||
form: (id: "A", count: 1),
|
||||
totals: (questions: 1, scored: 1, bonus: 0, points: 1.5, bonus-points: 0.0),
|
||||
dropped: (),
|
||||
extra: (:),
|
||||
questions: (
|
||||
(
|
||||
number: 1,
|
||||
points: 1.5,
|
||||
bonus: false,
|
||||
level: 2,
|
||||
level-name: "Understand",
|
||||
stem: [This sample question is replaced on the next export.],
|
||||
key: ("B",),
|
||||
options: (
|
||||
(letter: "A", position: 1, text: [Wrong.], correct: false, credit: 0.0),
|
||||
(letter: "B", position: 2, text: [Right.], correct: true, credit: 1.0),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
// coursebank:end data
|
||||
|
||||
#let extra = cb-data.at("extra", default: (:))
|
||||
#let accent = rgb(extra.at("accent", default: "#1f4e79"))
|
||||
|
||||
#set page(paper: extra.at("paper", default: "us-letter"), margin: 2cm)
|
||||
#set text(size: 10pt)
|
||||
|
||||
#let markup(v) = if type(v) == str { eval(v, mode: "markup") } else { v }
|
||||
#let fmt-points(p) = if p == calc.trunc(p) { str(calc.trunc(p)) } else { str(p) }
|
||||
|
||||
#let objectives-of(q) = q.at("learning-objectives", default: ()).join(", ")
|
||||
|
||||
= #cb-data.assessment.title — answer key (form #cb-data.form.id)
|
||||
|
||||
#text(size: 0.9em, style: "italic")[
|
||||
Letters below are the letters *as printed on this form*. Do not use this key on
|
||||
another form. Form seed #cb-data.form.at("seed", default: "—").
|
||||
]
|
||||
|
||||
#v(0.5em)
|
||||
|
||||
#table(
|
||||
columns: (auto, auto, auto, auto, 1fr),
|
||||
align: (right, center, center, right, left),
|
||||
table.header([*\#*], [*Key*], [*Level*], [*Pts*], [*Objectives*]),
|
||||
..cb-data
|
||||
.questions
|
||||
.map(q => (
|
||||
[#q.number],
|
||||
[*#q.at("key", default: ()).join("")*],
|
||||
[#str(q.at("level", default: "—"))],
|
||||
[#fmt-points(q.at("points", default: 0))],
|
||||
[#objectives-of(q)],
|
||||
))
|
||||
.flatten(),
|
||||
)
|
||||
|
||||
// ── Partial credit ──
|
||||
// Recorded against the letters the students actually saw, so a grader can apply it
|
||||
// without remapping anything.
|
||||
#{
|
||||
let rows = cb-data.questions.filter(q => q.at("credit-overrides", default: (:)).len() > 0)
|
||||
if rows.len() > 0 {
|
||||
heading(level: 2)[Partial credit]
|
||||
list(
|
||||
..rows.map(q => {
|
||||
let parts = q
|
||||
.at("credit-overrides", default: (:))
|
||||
.pairs()
|
||||
.map(pair => pair.at(0) + " = " + str(int(calc.round(pair.at(1) * 100))) + "%")
|
||||
[Question #q.number: #parts.join(", ")]
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Dropped ──
|
||||
#{
|
||||
let dropped = cb-data.at("dropped", default: ())
|
||||
if dropped.len() > 0 {
|
||||
heading(level: 2)[Dropped]
|
||||
[
|
||||
Question(s) #dropped.map(str).join(", ") were dropped after administration
|
||||
and are not printed.
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rationale ──
|
||||
// Everything below exists because a key you can grade from is not the same
|
||||
// document as a key you can defend a challenge with.
|
||||
#pagebreak(weak: true)
|
||||
|
||||
#heading(level: 2)[Rationale]
|
||||
|
||||
#for q in cb-data.questions {
|
||||
block(breakable: false, above: 1.2em)[
|
||||
#text(weight: "bold")[Question #q.number]
|
||||
#{
|
||||
let uid = q.at("uid", default: none)
|
||||
if uid != none { text(size: 0.85em, fill: luma(120))[ · #uid] }
|
||||
}
|
||||
|
||||
#markup(q.stem)
|
||||
|
||||
#for opt in q.at("options", default: ()) {
|
||||
let correct = opt.at("correct", default: false)
|
||||
let credit = opt.at("credit", default: 0.0)
|
||||
// Drawn rather than typed: a check-mark glyph is not in every font, and a
|
||||
// missing glyph on an answer key is a box where the answer should be.
|
||||
let marker = if correct {
|
||||
box(width: 0.55em, height: 0.55em, radius: 1pt, fill: accent)
|
||||
} else if credit > 0 {
|
||||
box(width: 0.55em, height: 0.55em, radius: 1pt, stroke: 0.6pt + accent)
|
||||
} else {
|
||||
box(width: 0.55em, height: 0.55em)
|
||||
}
|
||||
grid(
|
||||
columns: (1.2em, 1.4em, 1fr),
|
||||
gutter: 0.3em,
|
||||
[#marker],
|
||||
[#(opt.letter + ".")],
|
||||
[
|
||||
#markup(opt.text)
|
||||
#{
|
||||
let why = opt.at("explanation", default: opt.at("misconception", default: none))
|
||||
if why != none {
|
||||
linebreak()
|
||||
text(size: 0.85em, fill: luma(90), style: "italic")[#markup(why)]
|
||||
}
|
||||
}
|
||||
],
|
||||
)
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user