feat: improve typst handling
This commit is contained in:
@@ -18,14 +18,14 @@ pkg-config = "*"
|
||||
build = "cargo build --release"
|
||||
debug = "cargo build"
|
||||
tests = "cargo test"
|
||||
fmt = "cargo fmt"
|
||||
format = "cargo fmt"
|
||||
lint = { cmd = "cargo clippy --all-targets -- -D warnings", depends-on = [
|
||||
"fmt",
|
||||
"format",
|
||||
] }
|
||||
doc = "cargo doc --no-deps --open"
|
||||
clean = "cargo clean"
|
||||
install = "cargo install --path . --locked"
|
||||
check = { depends-on = ["fmt", "lint", "tests"] }
|
||||
check = { depends-on = ["format", "lint", "tests"] }
|
||||
build-lean = "cargo build --release --no-default-features"
|
||||
|
||||
[tasks.cb]
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@
|
||||
//! contain those routinely.
|
||||
//!
|
||||
//! [`students`] turns item statistics into per-objective standing, and is careful
|
||||
//! about what three questions can honestly support — it classifies on the observed
|
||||
//! about what three questions can honestly support: it classifies on the observed
|
||||
//! rate and reports confidence from the Wilson interval separately.
|
||||
//!
|
||||
//! [`calibrate`] is the arrow back to authoring, and the reason the system
|
||||
|
||||
@@ -881,6 +881,16 @@ mod tests {
|
||||
}
|
||||
|
||||
/// Twelve students; item 1 discriminates, item 2 is keyed backwards.
|
||||
///
|
||||
/// Item 2 is only *partly* reversed, and that is load-bearing rather than
|
||||
/// sloppy. The corrected point-biserial scores each item against the total of
|
||||
/// the *other* items, and with four items — one of them unanimous — item 2 is
|
||||
/// most of item 1's rest score. Make item 2 an exact complement of item 1 and
|
||||
/// `item1 + item2 == 1` for every student, so the total collapses to
|
||||
/// `2 + item4`, carries no ability signal at all, and both items come out at
|
||||
/// r = -0.71. The fixture then contradicts itself: the item it calls good is
|
||||
/// flagged for negative discrimination, and the reversed key is negative only
|
||||
/// because it mirrors item 1 rather than because it is miskeyed.
|
||||
fn sample() -> ResponseSet {
|
||||
let mut set = ResponseSet::new();
|
||||
for i in 0..12 {
|
||||
@@ -893,21 +903,25 @@ mod tests {
|
||||
if strong { "A" } else { "B" },
|
||||
if strong { 1.0 } else { 0.0 },
|
||||
));
|
||||
// Item 2: reversed, which is what a keying error looks like.
|
||||
// Item 2: weaker students do better on it, which is what a keying
|
||||
// error looks like. Half the strong group and two thirds of the weak
|
||||
// group get it, so it is reversed without mirroring item 1.
|
||||
let missed = if strong { i < 3 } else { i < 8 };
|
||||
set.rows.push(resp(
|
||||
&s,
|
||||
2,
|
||||
if strong { "C" } else { "D" },
|
||||
if strong { 0.0 } else { 1.0 },
|
||||
if missed { "C" } else { "D" },
|
||||
if missed { 0.0 } else { 1.0 },
|
||||
));
|
||||
// Item 3: everyone correct.
|
||||
set.rows.push(resp(&s, 3, "A", 1.0));
|
||||
// Item 4: gives the totals some spread.
|
||||
// Item 4: a second discriminating item, so that removing any one item
|
||||
// still leaves a rest score that tracks ability.
|
||||
set.rows.push(resp(
|
||||
&s,
|
||||
4,
|
||||
if i % 2 == 0 { "A" } else { "B" },
|
||||
if i % 2 == 0 { 1.0 } else { 0.0 },
|
||||
if strong { "A" } else { "B" },
|
||||
if strong { 1.0 } else { 0.0 },
|
||||
));
|
||||
}
|
||||
set
|
||||
|
||||
@@ -1220,7 +1220,7 @@ stem: Which mechanism best explains the sigmoidal binding curve?
|
||||
options:
|
||||
- { id: A, text: Ligand binding shifts the tetramer to a higher-affinity state, correct: true }
|
||||
- { id: B, text: Each subunit binds with the same fixed affinity throughout }
|
||||
- { id: C, text: Ligand is consumed as it binds, depleting the available pool }
|
||||
- { id: C, text: "Ligand is consumed as it binds, depleting the available pool" }
|
||||
- { id: D, text: The heme iron changes oxidation state upon binding }
|
||||
"#,
|
||||
);
|
||||
|
||||
+283
-353
@@ -5,40 +5,78 @@
|
||||
//! iterate on. `pixi run -e docs typst compile` turns the output of this module
|
||||
//! into a PDF.
|
||||
//!
|
||||
//! The emitted document is deliberately plain and self-contained: no imports, no
|
||||
//! template packages, nothing that can break because a package version moved. It
|
||||
//! is also meant to be edited. A generated exam is a starting point, and the
|
||||
//! output is formatted so that a human can reasonably open it and adjust spacing
|
||||
//! before printing.
|
||||
//! ## What changed, and why
|
||||
//!
|
||||
//! Every export takes a form, so form B's answer key is generated from the same
|
||||
//! permutation that produced form B's question paper. Keeping the key and the
|
||||
//! paper in one code path is the only way to be sure they agree — a mismatch is
|
||||
//! discovered by twenty-five students at once.
|
||||
//! This module used to build a document with `format!`. That made the position of
|
||||
//! the points label a Rust change, put a `#grid` call in a match arm, and meant
|
||||
//! the only way to move something on the page was to fork the crate. It also made
|
||||
//! a generated exam a dead end: you could edit the output, but the next export
|
||||
//! overwrote the edit.
|
||||
//!
|
||||
//! So the tool no longer writes documents. It loads a Typst file that you own,
|
||||
//! finds the markers in it, and injects data:
|
||||
//!
|
||||
//! ```typst
|
||||
//! // coursebank:begin questions
|
||||
//! #render-question((number: 1, stem: [Sample.], options: ()))
|
||||
//! // coursebank:end questions
|
||||
//! ```
|
||||
//!
|
||||
//! `render-question` is defined in your template. What the payload contains is
|
||||
//! governed by [`config::RenderConfig`]; where it lands and how it looks is
|
||||
//! governed by the template. The default templates are compiled into the binary,
|
||||
//! and `coursebank template dump` writes them into `templates/` so that
|
||||
//! customizing means editing a file. See [`template`] for the marker syntax, the
|
||||
//! slot list, and the lookup order.
|
||||
//!
|
||||
//! ## What is still enforced here
|
||||
//!
|
||||
//! Two invariants survived the rewrite, because both are the kind of mistake a
|
||||
//! room full of students discovers simultaneously.
|
||||
//!
|
||||
//! **A form's key is derived from the same permutation as its paper.** Option order
|
||||
//! comes from [`select::option_order`](crate::select::option_order) against the
|
||||
//! form's recorded seed, never from anything stored, so every export of form B
|
||||
//! agrees with every other. The paper, the key, and the answer sheet are all built
|
||||
//! from one [`payload::Payload`].
|
||||
//!
|
||||
//! **The paper's payload does not contain the answer.** Not `correct: false`, not a
|
||||
//! flag to check — the field is absent. See [`config::Reveal`]. A template cannot
|
||||
//! leak what it was never given, and that stays true through every future edit of
|
||||
//! the template by someone who has not read this comment.
|
||||
|
||||
pub mod config;
|
||||
pub mod payload;
|
||||
pub mod template;
|
||||
pub mod value;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub use config::{
|
||||
ConfigFile, ContentMode, Fields, LetterStyle, Overrides, RenderConfig, Reveal, StimulusMode,
|
||||
Variant, CONFIG_FILE, CONFIG_TEMPLATE,
|
||||
};
|
||||
pub use payload::Payload;
|
||||
pub use template::{Origin, Slot, Template};
|
||||
pub use value::Value;
|
||||
|
||||
use crate::assessment::{AssessmentFile, Form};
|
||||
use crate::catalog::Catalog;
|
||||
use crate::course::CourseFile;
|
||||
use crate::course::Layout;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::markup;
|
||||
use crate::select;
|
||||
|
||||
/// Options for a printed exam.
|
||||
/// What to render.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Options {
|
||||
/// Which form to render.
|
||||
/// Which form.
|
||||
pub form: Form,
|
||||
/// Whether to leave a name and student-id block at the top.
|
||||
pub name_block: bool,
|
||||
/// Whether to show the points each question is worth.
|
||||
pub show_points: bool,
|
||||
/// Whether to start each question on a new page. Occasionally worth it for an
|
||||
/// exam with long stimuli.
|
||||
pub page_per_item: bool,
|
||||
/// Paper size, as Typst names it.
|
||||
pub paper: String,
|
||||
/// Base font size.
|
||||
pub font_size: String,
|
||||
/// Which document.
|
||||
pub variant: Variant,
|
||||
/// A template path that overrides the usual lookup. Takes precedence over
|
||||
/// `template` in the render config.
|
||||
pub template: Option<PathBuf>,
|
||||
/// What to put in the payload, and how.
|
||||
pub config: RenderConfig,
|
||||
}
|
||||
|
||||
impl Default for Options {
|
||||
@@ -50,22 +88,195 @@ impl Default for Options {
|
||||
shuffle_items: false,
|
||||
shuffle_options: false,
|
||||
},
|
||||
name_block: true,
|
||||
show_points: true,
|
||||
page_per_item: false,
|
||||
paper: "us-letter".to_string(),
|
||||
font_size: "11pt".to_string(),
|
||||
variant: Variant::Exam,
|
||||
template: None,
|
||||
config: RenderConfig::for_variant(Variant::Exam),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders the question paper.
|
||||
impl Options {
|
||||
/// Options for one variant, with that variant's default configuration.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `variant` - which document.
|
||||
/// * `form` - the form to render.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The options.
|
||||
pub fn new(variant: Variant, form: Form) -> Options {
|
||||
Options {
|
||||
form,
|
||||
variant,
|
||||
template: None,
|
||||
config: RenderConfig::for_variant(variant),
|
||||
}
|
||||
}
|
||||
|
||||
/// The template path to use, preferring the explicit one.
|
||||
fn template_path(&self) -> Option<&Path> {
|
||||
self.template.as_deref().or(self.config.template.as_deref())
|
||||
}
|
||||
}
|
||||
|
||||
/// A finished document, with everything a caller needs to explain it.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Rendered {
|
||||
/// Which document this is.
|
||||
pub variant: Variant,
|
||||
/// Where the template came from.
|
||||
pub origin: Origin,
|
||||
/// Which slots the template declared and this render filled.
|
||||
pub slots: Vec<Slot>,
|
||||
/// The Typst source.
|
||||
pub text: String,
|
||||
/// The payload, kept so a caller can also write it as JSON without rebuilding.
|
||||
pub payload: Payload,
|
||||
/// Advisory problems: markup that will probably confuse the Typst compiler, and
|
||||
/// a template that declared no markers at all.
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// Renders one document.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `catalog` - the loaded course.
|
||||
/// * `record` - the assessment record.
|
||||
/// * `opts` - rendering options.
|
||||
/// * `opts` - what to render.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The finished document.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::Unresolved`] when a placement references a missing item,
|
||||
/// [`Error::Io`] when an explicitly requested template cannot be read, and
|
||||
/// [`Error::Invalid`] when the template's markers are malformed or every placement
|
||||
/// is marked dropped.
|
||||
pub fn render(catalog: &Catalog, record: &AssessmentFile, opts: &Options) -> Result<Rendered> {
|
||||
let template = template::load(
|
||||
&catalog.layout,
|
||||
opts.variant,
|
||||
Some(record.assessment.id.as_str()),
|
||||
opts.template_path(),
|
||||
)?;
|
||||
|
||||
let payload = payload::build(catalog, record, &opts.form, &opts.config)?;
|
||||
|
||||
if payload.questions.is_empty() {
|
||||
return Err(Error::Invalid(vec![
|
||||
"this assessment has no printable items; every placement is marked dropped".to_string(),
|
||||
]));
|
||||
}
|
||||
|
||||
// Only build what the template asked for. A paper template that never mentions
|
||||
// `data` should not pay for a second copy of every stem.
|
||||
let mut bodies = Vec::new();
|
||||
if template.wants(Slot::Meta) {
|
||||
bodies.push((Slot::Meta, payload::meta_body(&payload, &opts.config)));
|
||||
}
|
||||
if template.wants(Slot::Questions) {
|
||||
bodies.push((
|
||||
Slot::Questions,
|
||||
payload::questions_body(&payload, &opts.config),
|
||||
));
|
||||
}
|
||||
if template.wants(Slot::Data) {
|
||||
bodies.push((Slot::Data, payload::data_body(&payload, &opts.config)));
|
||||
}
|
||||
|
||||
let mut warnings = payload::check(&payload, &opts.config);
|
||||
if template.is_inert() {
|
||||
warnings.push(format!(
|
||||
"the template {} declares no coursebank markers, so no questions were injected; add \
|
||||
`// coursebank:questions` where they belong",
|
||||
template.origin
|
||||
));
|
||||
}
|
||||
|
||||
Ok(Rendered {
|
||||
variant: opts.variant,
|
||||
origin: template.origin.clone(),
|
||||
slots: template.slots(),
|
||||
text: template.render(&bodies),
|
||||
payload,
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
|
||||
/// Builds the payload without rendering a template.
|
||||
///
|
||||
/// For writing the questions out as JSON, which is what a hand-written Typst exam
|
||||
/// that already calls `json("questions.json")` wants.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `catalog` - the loaded course.
|
||||
/// * `record` - the assessment record.
|
||||
/// * `opts` - what to render; only the form and config are consulted.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The payload.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::Unresolved`] when a placement references a missing item.
|
||||
pub fn build_payload(
|
||||
catalog: &Catalog,
|
||||
record: &AssessmentFile,
|
||||
opts: &Options,
|
||||
) -> Result<Payload> {
|
||||
payload::build(catalog, record, &opts.form, &opts.config)
|
||||
}
|
||||
|
||||
/// The path a course's Typst configuration lives at.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `layout` - the course layout.
|
||||
pub fn config_path(layout: &Layout) -> PathBuf {
|
||||
template::dir(layout).join(CONFIG_FILE)
|
||||
}
|
||||
|
||||
/// Loads a course's Typst configuration.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `layout` - the course layout.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The parsed config file, or an empty one when `templates/typst.yaml` is absent.
|
||||
/// Absence is not an error: a course that has never customized anything should
|
||||
/// export without being told to write a config file first.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::Yaml`] when the file exists but does not parse.
|
||||
pub fn load_config(layout: &Layout) -> Result<ConfigFile> {
|
||||
let path = config_path(layout);
|
||||
if path.is_file() {
|
||||
crate::yaml::read(&path)
|
||||
} else {
|
||||
Ok(ConfigFile::default())
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders the question paper.
|
||||
///
|
||||
/// Kept so existing callers keep working. New code should use [`render`], which
|
||||
/// also reports which template was used and what it warned about.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `catalog` - the loaded course.
|
||||
/// * `record` - the assessment record.
|
||||
/// * `opts` - rendering options; the variant is forced to [`Variant::Exam`].
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
@@ -73,120 +284,9 @@ impl Default for Options {
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::Unresolved`] when a placement references a missing item.
|
||||
/// As [`render`].
|
||||
pub fn exam(catalog: &Catalog, record: &AssessmentFile, opts: &Options) -> Result<String> {
|
||||
let course = &catalog.course;
|
||||
let mut out = String::new();
|
||||
out.push_str(&preamble(course, record, opts));
|
||||
|
||||
if opts.name_block {
|
||||
out.push_str(
|
||||
"#block(above: 1em, below: 1.5em)[\n \
|
||||
#grid(columns: (auto, 1fr, auto, 1fr), gutter: 0.6em,\n \
|
||||
[*Name*], [#box(width: 100%, repeat[.])],\n \
|
||||
[*Student ID*], [#box(width: 100%, repeat[.])],\n )\n]\n\n",
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(instructions) = &record.assessment.instructions {
|
||||
out.push_str(&format!(
|
||||
"#block(fill: luma(245), inset: 8pt, radius: 3pt, width: 100%)[\n {}\n]\n\n",
|
||||
markup::to_typst(instructions)
|
||||
));
|
||||
}
|
||||
|
||||
let default_points = course.policy.points_per_item;
|
||||
let mut printed = 0usize;
|
||||
|
||||
for placement in select::layout(record, &opts.form) {
|
||||
if placement.dropped {
|
||||
continue;
|
||||
}
|
||||
let entry = catalog.require(&placement.item)?;
|
||||
let item = &entry.item;
|
||||
printed += 1;
|
||||
|
||||
if opts.page_per_item && printed > 1 {
|
||||
out.push_str("#pagebreak()\n\n");
|
||||
}
|
||||
|
||||
// A stimulus shared by several items is printed with each of them. That
|
||||
// repeats material, but a student should never have to flip pages to find
|
||||
// the passage a question refers to.
|
||||
if let Some(id) = &item.stimulus {
|
||||
if let Some(stimulus) = course.stimuli.get(id) {
|
||||
out.push_str(&format!(
|
||||
"#block(stroke: 0.5pt + luma(180), inset: 8pt, radius: 3pt, width: 100%)[\n \
|
||||
{}\n]\n",
|
||||
markup::to_typst(&stimulus.body)
|
||||
));
|
||||
if let Some(caption) = &stimulus.caption {
|
||||
out.push_str(&format!(
|
||||
"#block(above: 0.3em)[#text(size: 0.85em, style: \"italic\")[{}]]\n",
|
||||
markup::to_typst(caption)
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let points = placement
|
||||
.points
|
||||
.unwrap_or_else(|| item.points(default_points));
|
||||
let label = if placement.bonus {
|
||||
if opts.show_points {
|
||||
format!(
|
||||
" #text(fill: rgb(\"#666666\"))[(bonus, {})]",
|
||||
plural_points(points)
|
||||
)
|
||||
} else {
|
||||
" #text(fill: rgb(\"#666666\"))[(bonus)]".to_string()
|
||||
}
|
||||
} else if opts.show_points {
|
||||
format!(
|
||||
" #text(fill: rgb(\"#666666\"))[({})]",
|
||||
plural_points(points)
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
// The question number is the recorded one, not the printed position, so a
|
||||
// scanned answer sheet still joins to the assessment record.
|
||||
out.push_str(&format!(
|
||||
"#block(above: 1.2em, below: 0.5em)[*{}.*{label} {}]\n",
|
||||
placement.number,
|
||||
markup::to_typst(&item.stem)
|
||||
));
|
||||
|
||||
if item.is_multi_key() {
|
||||
out.push_str(
|
||||
"#block(below: 0.4em)[#text(size: 0.9em, style: \"italic\")[Select all that \
|
||||
apply.]]\n",
|
||||
);
|
||||
}
|
||||
|
||||
let order = select::option_order(&opts.form, &placement.item, item.options.len());
|
||||
out.push_str("#block(inset: (left: 1.2em))[\n");
|
||||
for (position, source_index) in order.iter().enumerate() {
|
||||
let choice = &item.options[*source_index];
|
||||
// Options are relabeled by printed position, so a shuffled form still
|
||||
// reads A, B, C, D.
|
||||
let letter = (b'A' + position as u8) as char;
|
||||
out.push_str(&format!(
|
||||
" #grid(columns: (1.4em, 1fr), gutter: 0.2em)[{letter}.][{}]\n",
|
||||
markup::to_typst(&choice.text)
|
||||
));
|
||||
}
|
||||
out.push_str("]\n\n");
|
||||
}
|
||||
|
||||
if printed == 0 {
|
||||
return Err(Error::Invalid(vec![
|
||||
"this assessment has no printable items; every placement is marked dropped".to_string(),
|
||||
]));
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
render_variant(catalog, record, opts, Variant::Exam)
|
||||
}
|
||||
|
||||
/// Renders the answer key.
|
||||
@@ -195,7 +295,7 @@ pub fn exam(catalog: &Catalog, record: &AssessmentFile, opts: &Options) -> Resul
|
||||
///
|
||||
/// * `catalog` - the loaded course.
|
||||
/// * `record` - the assessment record.
|
||||
/// * `opts` - rendering options, whose form determines the letters.
|
||||
/// * `opts` - rendering options; the variant is forced to [`Variant::Key`].
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
@@ -203,107 +303,18 @@ pub fn exam(catalog: &Catalog, record: &AssessmentFile, opts: &Options) -> Resul
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::Unresolved`] when a placement references a missing item.
|
||||
/// As [`render`].
|
||||
pub fn answer_key(catalog: &Catalog, record: &AssessmentFile, opts: &Options) -> Result<String> {
|
||||
let mut out = String::new();
|
||||
out.push_str(&format!(
|
||||
"#set page(paper: \"{}\", margin: 2cm)\n#set text(size: 10pt)\n\n\
|
||||
= {} — answer key (form {})\n\n",
|
||||
opts.paper,
|
||||
escape(&record.assessment.title),
|
||||
opts.form.id
|
||||
));
|
||||
|
||||
out.push_str(
|
||||
"#text(size: 0.9em, style: \"italic\")[Letters below are the letters *as printed on this \
|
||||
form*. Do not use this key on another form.]\n\n",
|
||||
);
|
||||
|
||||
out.push_str(
|
||||
"#table(\n columns: (auto, auto, auto, 1fr),\n align: (right, center, center, left),\n \
|
||||
table.header([*\\#*], [*Key*], [*Level*], [*Objectives*]),\n",
|
||||
);
|
||||
|
||||
for placement in select::layout(record, &opts.form) {
|
||||
if placement.dropped {
|
||||
continue;
|
||||
}
|
||||
let entry = catalog.require(&placement.item)?;
|
||||
let item = &entry.item;
|
||||
let order = select::option_order(&opts.form, &placement.item, item.options.len());
|
||||
|
||||
// Map the source option index to the letter it was printed as.
|
||||
let mut printed_letters = Vec::new();
|
||||
for (position, source_index) in order.iter().enumerate() {
|
||||
if item.options[*source_index].correct {
|
||||
printed_letters.push(((b'A' + position as u8) as char).to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let objectives = if placement.learning_objectives.is_empty() {
|
||||
item.learning_objectives.join(", ")
|
||||
} else {
|
||||
placement.learning_objectives.join(", ")
|
||||
};
|
||||
|
||||
out.push_str(&format!(
|
||||
" [{}], [*{}*], [{}], [{}],\n",
|
||||
placement.number,
|
||||
printed_letters.join(""),
|
||||
placement
|
||||
.level
|
||||
.map(|l| l.code().to_string())
|
||||
.unwrap_or_else(|| "-".into()),
|
||||
escape(&objectives)
|
||||
));
|
||||
}
|
||||
out.push_str(")\n\n");
|
||||
|
||||
// Partial credit decisions belong on the key, where the grader will see them.
|
||||
let overrides: Vec<String> = record
|
||||
.items
|
||||
.iter()
|
||||
.filter(|p| !p.credit_overrides.is_empty())
|
||||
.map(|p| {
|
||||
let list: Vec<String> = p
|
||||
.credit_overrides
|
||||
.iter()
|
||||
.map(|(letter, credit)| format!("{letter} = {:.0}%", credit * 100.0))
|
||||
.collect();
|
||||
format!("Question {}: {}", p.number, list.join(", "))
|
||||
})
|
||||
.collect();
|
||||
if !overrides.is_empty() {
|
||||
out.push_str("== Partial credit\n\n");
|
||||
for line in overrides {
|
||||
out.push_str(&format!("- {}\n", escape(&line)));
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
let dropped: Vec<String> = record
|
||||
.items
|
||||
.iter()
|
||||
.filter(|p| p.dropped)
|
||||
.map(|p| p.number.to_string())
|
||||
.collect();
|
||||
if !dropped.is_empty() {
|
||||
out.push_str(&format!(
|
||||
"== Dropped\n\nQuestion(s) {} were dropped and are not printed.\n\n",
|
||||
dropped.join(", ")
|
||||
));
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
render_variant(catalog, record, opts, Variant::Key)
|
||||
}
|
||||
|
||||
/// Renders a bubble sheet matching the form.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `catalog` - the loaded course, for option counts.
|
||||
/// * `catalog` - the loaded course.
|
||||
/// * `record` - the assessment record.
|
||||
/// * `opts` - rendering options.
|
||||
/// * `opts` - rendering options; the variant is forced to [`Variant::AnswerSheet`].
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
@@ -311,132 +322,51 @@ pub fn answer_key(catalog: &Catalog, record: &AssessmentFile, opts: &Options) ->
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::Unresolved`] when a placement references a missing item.
|
||||
/// As [`render`].
|
||||
pub fn bubble_sheet(catalog: &Catalog, record: &AssessmentFile, opts: &Options) -> Result<String> {
|
||||
let mut out = format!(
|
||||
"#set page(paper: \"{}\", margin: 1.5cm)\n#set text(size: 10pt)\n\n\
|
||||
= {} — answer sheet (form {})\n\n\
|
||||
#grid(columns: (auto, 1fr, auto, 1fr), gutter: 0.6em,\n \
|
||||
[*Name*], [#box(width: 100%, repeat[.])],\n \
|
||||
[*Student ID*], [#box(width: 100%, repeat[.])],\n)\n\n\
|
||||
#v(1em)\n",
|
||||
opts.paper,
|
||||
escape(&record.assessment.title),
|
||||
opts.form.id
|
||||
);
|
||||
render_variant(catalog, record, opts, Variant::AnswerSheet)
|
||||
}
|
||||
|
||||
out.push_str("#columns(2)[\n");
|
||||
for placement in select::layout(record, &opts.form) {
|
||||
if placement.dropped {
|
||||
continue;
|
||||
}
|
||||
let entry = catalog.require(&placement.item)?;
|
||||
let count = entry.item.options.len();
|
||||
let bubbles: Vec<String> = (0..count)
|
||||
.map(|i| {
|
||||
let letter = (b'A' + i as u8) as char;
|
||||
format!("#circle(radius: 0.42em, stroke: 0.5pt)[#align(center + horizon)[#text(size: 0.7em)[{letter}]]]")
|
||||
})
|
||||
.collect();
|
||||
out.push_str(&format!(
|
||||
" #block(below: 0.45em)[#box(width: 2em)[{}.] {}]\n",
|
||||
placement.number,
|
||||
bubbles.join(" ")
|
||||
));
|
||||
/// Renders one variant, substituting that variant's default config when the caller
|
||||
/// passed the config for a different one.
|
||||
fn render_variant(
|
||||
catalog: &Catalog,
|
||||
record: &AssessmentFile,
|
||||
opts: &Options,
|
||||
variant: Variant,
|
||||
) -> Result<String> {
|
||||
let mut opts = opts.clone();
|
||||
if opts.variant != variant {
|
||||
// The caller asked for a different document than the config describes, so
|
||||
// the config's `reveal` almost certainly belongs to the other one. Taking
|
||||
// the variant's own default is the safe reading, and the direction that
|
||||
// matters is the paper: never inherit a key's `reveal`.
|
||||
opts.config = RenderConfig::for_variant(variant);
|
||||
opts.variant = variant;
|
||||
}
|
||||
out.push_str("]\n");
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Builds the document preamble.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `course` - the course.
|
||||
/// * `record` - the assessment record.
|
||||
/// * `opts` - rendering options.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Typst setup and a title block.
|
||||
fn preamble(course: &CourseFile, record: &AssessmentFile, opts: &Options) -> String {
|
||||
let form_note = if record.forms.len() > 1 {
|
||||
format!(" · Form {}", opts.form.id)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let date = record
|
||||
.assessment
|
||||
.date
|
||||
.map(|d| d.to_string())
|
||||
.unwrap_or_default();
|
||||
let minutes = record
|
||||
.assessment
|
||||
.minutes_allowed
|
||||
.map(|m| format!(" · {m:.0} minutes"))
|
||||
.unwrap_or_default();
|
||||
|
||||
format!(
|
||||
"#set page(\n paper: \"{paper}\",\n margin: 2cm,\n \
|
||||
header: [#text(size: 0.85em)[{code} · {title}{form_note}]],\n \
|
||||
footer: context [#text(size: 0.85em)[Page #counter(page).display() of \
|
||||
#counter(page).final().first()]],\n)\n\
|
||||
#set text(size: {size})\n\
|
||||
#set par(justify: false, leading: 0.65em)\n\n\
|
||||
#align(center)[\n #text(size: 1.4em, weight: \"bold\")[{title}]\n \\\n \
|
||||
#text(size: 0.95em)[{code} — {course_title} · {term}]\n \\\n \
|
||||
#text(size: 0.9em)[{date}{minutes}]\n]\n\n",
|
||||
paper = opts.paper,
|
||||
size = opts.font_size,
|
||||
code = escape(&course.course.code),
|
||||
course_title = escape(&course.course.title),
|
||||
term = escape(
|
||||
record
|
||||
.assessment
|
||||
.term
|
||||
.as_deref()
|
||||
.unwrap_or(&course.course.term)
|
||||
),
|
||||
title = escape(&record.assessment.title),
|
||||
form_note = form_note,
|
||||
date = date,
|
||||
minutes = minutes,
|
||||
)
|
||||
}
|
||||
|
||||
/// Formats a point value with the right plural.
|
||||
fn plural_points(points: f64) -> String {
|
||||
if (points - 1.0).abs() < 1e-9 {
|
||||
"1 point".to_string()
|
||||
} else if (points.fract()).abs() < 1e-9 {
|
||||
format!("{points:.0} points")
|
||||
} else {
|
||||
format!("{points} points")
|
||||
}
|
||||
}
|
||||
|
||||
/// Escapes text for Typst content mode.
|
||||
fn escape(s: &str) -> String {
|
||||
markup::to_typst(s)
|
||||
Ok(render(catalog, record, &opts)?.text)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::assessment::Placement;
|
||||
use crate::select;
|
||||
|
||||
#[test]
|
||||
fn point_labels_are_pluralized() {
|
||||
assert_eq!(plural_points(1.0), "1 point");
|
||||
assert_eq!(plural_points(2.0), "2 points");
|
||||
assert_eq!(plural_points(1.5), "1.5 points");
|
||||
fn options_default_to_the_paper_and_withhold_the_key() {
|
||||
let opts = Options::default();
|
||||
assert_eq!(opts.variant, Variant::Exam);
|
||||
assert!(!opts.config.reveal.shows_key());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escaping_protects_typst_syntax() {
|
||||
assert_eq!(escape("email me @ home"), "email me \\@ home");
|
||||
assert_eq!(escape("a < b"), "a \\< b");
|
||||
fn an_explicit_template_beats_the_config() {
|
||||
let mut opts = Options::default();
|
||||
opts.config.template = Some(PathBuf::from("from-config.typ"));
|
||||
assert_eq!(opts.template_path(), Some(Path::new("from-config.typ")));
|
||||
opts.template = Some(PathBuf::from("from-cli.typ"));
|
||||
assert_eq!(opts.template_path(), Some(Path::new("from-cli.typ")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -452,8 +382,8 @@ mod tests {
|
||||
let order = select::option_order(&form, "bank::q-1", 4);
|
||||
let correct_source = 0usize;
|
||||
let printed_position = order.iter().position(|i| *i == correct_source).unwrap();
|
||||
let letter = (b'A' + printed_position as u8) as char;
|
||||
assert!(('A'..='D').contains(&letter));
|
||||
let letter = LetterStyle::Upper.label(printed_position);
|
||||
assert!(["A", "B", "C", "D"].contains(&letter.as_str()));
|
||||
// And it is reproducible.
|
||||
let again = select::option_order(&form, "bank::q-1", 4);
|
||||
assert_eq!(order, again);
|
||||
|
||||
@@ -0,0 +1,746 @@
|
||||
//! What gets emitted, and in what shape.
|
||||
//!
|
||||
//! This module holds the knobs that used to be `format!` calls. The split is
|
||||
//! deliberate: a template decides how a question *looks*, and this config decides
|
||||
//! what the template is *told*. Anything you can express by rearranging boxes
|
||||
//! belongs in the template, not here.
|
||||
//!
|
||||
//! The one setting that is not cosmetic is [`Reveal`]. It controls whether the
|
||||
//! payload contains the answer at all, and the reason it is a config value rather
|
||||
//! than a template concern is that a template cannot be trusted with it. If the
|
||||
//! exam paper's payload carries `correct: true`, then every future edit to that
|
||||
//! template is one `if` statement away from printing the key, and the failure mode
|
||||
//! is discovered by a room full of students. Withholding the field is the only
|
||||
//! version of this that stays correct under editing.
|
||||
//!
|
||||
//! Config is resolved in three layers, each overriding the last: the built-in
|
||||
//! defaults for the variant, the `defaults:` block of `templates/typst.yaml`, and
|
||||
//! that file's `variants:` block. `coursebank template config` writes the whole
|
||||
//! resolved thing out so there is no guessing about what applied.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// Which document is being produced.
|
||||
///
|
||||
/// A variant is not a style. It is a different set of facts: the paper withholds
|
||||
/// the key, the key withholds the questions, and the answer sheet needs only the
|
||||
/// option counts. Each has its own template and its own default [`Reveal`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum Variant {
|
||||
/// The question paper a student writes on.
|
||||
Exam,
|
||||
/// The grader's answer key.
|
||||
Key,
|
||||
/// A bubble sheet matching the form.
|
||||
AnswerSheet,
|
||||
}
|
||||
|
||||
impl Variant {
|
||||
/// Every variant, in the order `export` writes them.
|
||||
pub const ALL: [Variant; 3] = [Variant::Exam, Variant::Key, Variant::AnswerSheet];
|
||||
|
||||
/// The token used on the command line, in config keys, and in file names.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Variant::Exam => "exam",
|
||||
Variant::Key => "key",
|
||||
Variant::AnswerSheet => "answer-sheet",
|
||||
}
|
||||
}
|
||||
|
||||
/// The variant for a token.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `s` - the token, e.g. `"answer-sheet"`.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The variant.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::Usage`] naming the valid tokens.
|
||||
pub fn parse(s: &str) -> Result<Variant> {
|
||||
let normalized = s.trim().to_ascii_lowercase().replace('_', "-");
|
||||
Variant::ALL
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|v| v.as_str() == normalized)
|
||||
.ok_or_else(|| {
|
||||
Error::usage(format!(
|
||||
"unknown template variant `{s}`; expected one of {}",
|
||||
Variant::ALL
|
||||
.iter()
|
||||
.map(|v| v.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// The file name this variant's template is looked up under.
|
||||
pub fn template_file(self) -> String {
|
||||
format!("{}.typ", self.as_str())
|
||||
}
|
||||
|
||||
/// The suffix appended to an exported file's stem.
|
||||
///
|
||||
/// The paper gets no suffix because it is the thing you print most often and
|
||||
/// `exam-2-A.typ` reads better than `exam-2-A-exam.typ`.
|
||||
pub fn suffix(self) -> &'static str {
|
||||
match self {
|
||||
Variant::Exam => "",
|
||||
Variant::Key => "-key",
|
||||
Variant::AnswerSheet => "-answer-sheet",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How much of the answer side of an item reaches the payload.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum Reveal {
|
||||
/// Nothing. No `correct`, no `credit`, no keyed letters, no rationale. What a
|
||||
/// student form must use.
|
||||
Nothing,
|
||||
/// Which options are correct, what credit each earns, and any recorded
|
||||
/// partial-credit overrides. Enough to grade with.
|
||||
Key,
|
||||
/// Everything, including instructor rationale, targeted misconceptions, and
|
||||
/// the record's private notes. For a review copy that never leaves your desk.
|
||||
Everything,
|
||||
}
|
||||
|
||||
impl Reveal {
|
||||
/// Whether keyed letters, `correct`, and `credit` are emitted.
|
||||
pub fn shows_key(self) -> bool {
|
||||
self != Reveal::Nothing
|
||||
}
|
||||
|
||||
/// Whether rationale, misconceptions, and private notes are emitted.
|
||||
pub fn shows_rationale(self) -> bool {
|
||||
self == Reveal::Everything
|
||||
}
|
||||
}
|
||||
|
||||
/// How option labels are generated.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum LetterStyle {
|
||||
/// `A`, `B`, `C`.
|
||||
Upper,
|
||||
/// `a`, `b`, `c`.
|
||||
Lower,
|
||||
/// `1`, `2`, `3`.
|
||||
Numeric,
|
||||
/// `i`, `ii`, `iii`.
|
||||
Roman,
|
||||
/// No label; the template supplies its own, usually via `numbering`.
|
||||
Nothing,
|
||||
}
|
||||
|
||||
impl LetterStyle {
|
||||
/// The label for a zero-based printed position.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `position` - the printed position, counting from zero.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The label, or an empty string for [`LetterStyle::Nothing`].
|
||||
pub fn label(self, position: usize) -> String {
|
||||
match self {
|
||||
LetterStyle::Upper => alphabetic(position, true),
|
||||
LetterStyle::Lower => alphabetic(position, false),
|
||||
LetterStyle::Numeric => (position + 1).to_string(),
|
||||
LetterStyle::Roman => roman(position + 1),
|
||||
LetterStyle::Nothing => String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A spreadsheet-style label: `A`..`Z`, then `AA`.
|
||||
///
|
||||
/// Eight options is the schema's ceiling, so the second character is unreachable
|
||||
/// in practice. It is here so that raising that ceiling does not silently produce
|
||||
/// `[` as an option label, which is what `b'A' + 26` gives you.
|
||||
fn alphabetic(position: usize, upper: bool) -> String {
|
||||
let base = if upper { b'A' } else { b'a' };
|
||||
let mut n = position;
|
||||
let mut letters = Vec::new();
|
||||
loop {
|
||||
letters.push((base + (n % 26) as u8) as char);
|
||||
if n < 26 {
|
||||
break;
|
||||
}
|
||||
n = n / 26 - 1;
|
||||
}
|
||||
letters.iter().rev().collect()
|
||||
}
|
||||
|
||||
/// A lowercase Roman numeral for a one-based position.
|
||||
fn roman(mut n: usize) -> String {
|
||||
const TABLE: [(usize, &str); 13] = [
|
||||
(1000, "m"),
|
||||
(900, "cm"),
|
||||
(500, "d"),
|
||||
(400, "cd"),
|
||||
(100, "c"),
|
||||
(90, "xc"),
|
||||
(50, "l"),
|
||||
(40, "xl"),
|
||||
(10, "x"),
|
||||
(9, "ix"),
|
||||
(5, "v"),
|
||||
(4, "iv"),
|
||||
(1, "i"),
|
||||
];
|
||||
let mut out = String::new();
|
||||
for (value, numeral) in TABLE {
|
||||
while n >= value {
|
||||
out.push_str(numeral);
|
||||
n -= value;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// How markup-bearing fields are emitted.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum ContentMode {
|
||||
/// As content blocks, `[...]`, which Typst parses at compile time. Errors
|
||||
/// point at the generated file, and no `eval` is needed.
|
||||
Content,
|
||||
/// As quoted strings, which a template evaluates with
|
||||
/// `eval(q.stem, mode: "markup")`. Required if the same payload is also
|
||||
/// consumed as JSON, since JSON has no content type.
|
||||
Str,
|
||||
}
|
||||
|
||||
impl ContentMode {
|
||||
/// Whether markup becomes a content block rather than a string.
|
||||
pub fn is_content(self) -> bool {
|
||||
self == ContentMode::Content
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a shared stimulus travels with each item that uses it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum StimulusMode {
|
||||
/// Repeat the body with every item that references it. Wasteful on paper, but
|
||||
/// a student should never have to turn a page to find the passage a question
|
||||
/// is about.
|
||||
Inline,
|
||||
/// Emit only the id on the item, and the bodies once in the metadata under
|
||||
/// `stimuli`. For a template that prints a testlet header above its group.
|
||||
Shared,
|
||||
/// Leave stimuli out.
|
||||
Omit,
|
||||
}
|
||||
|
||||
/// Which optional per-question fields are emitted.
|
||||
///
|
||||
/// Everything here defaults on except the two heavy ones. Turning a field off is
|
||||
/// about payload noise, not secrecy — [`Reveal`] is what governs secrecy.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Fields {
|
||||
/// The item's global id, `bank::item`. Useful printed in small grey type on a
|
||||
/// review copy; never wanted on a student form.
|
||||
#[serde(default = "yes")]
|
||||
pub uid: bool,
|
||||
/// The item's short title.
|
||||
#[serde(default = "yes")]
|
||||
pub title: bool,
|
||||
/// Point value.
|
||||
#[serde(default = "yes")]
|
||||
pub points: bool,
|
||||
/// Cognitive level, as both a number and a name.
|
||||
#[serde(default = "yes")]
|
||||
pub level: bool,
|
||||
/// Learning objective ids.
|
||||
#[serde(default = "yes")]
|
||||
pub objectives: bool,
|
||||
/// Topic tags.
|
||||
#[serde(default = "yes")]
|
||||
pub topics: bool,
|
||||
/// Figures and data files attached to the item.
|
||||
#[serde(default = "yes")]
|
||||
pub assets: bool,
|
||||
/// The letter the option carries in the bank, before shuffling. On a key this
|
||||
/// is what lets you find the option in the YAML.
|
||||
#[serde(default = "yes")]
|
||||
pub source_letters: bool,
|
||||
/// The authored predictions in the item's `design` block.
|
||||
#[serde(default = "no")]
|
||||
pub design: bool,
|
||||
/// Pooled statistics from previous administrations.
|
||||
#[serde(default = "no")]
|
||||
pub calibration: bool,
|
||||
}
|
||||
|
||||
impl Default for Fields {
|
||||
fn default() -> Fields {
|
||||
Fields {
|
||||
uid: true,
|
||||
title: true,
|
||||
points: true,
|
||||
level: true,
|
||||
objectives: true,
|
||||
topics: true,
|
||||
assets: true,
|
||||
source_letters: true,
|
||||
design: false,
|
||||
calibration: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The resolved configuration for rendering one variant.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RenderConfig {
|
||||
/// A template path that overrides the usual lookup.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub template: Option<PathBuf>,
|
||||
|
||||
/// The Typst function the `questions` slot calls, once per item, with a
|
||||
/// single dictionary argument. The template defines it.
|
||||
#[serde(default = "default_question_fn")]
|
||||
pub question_fn: String,
|
||||
|
||||
/// The binding the `meta` slot declares.
|
||||
#[serde(default = "default_meta_binding")]
|
||||
pub meta_binding: String,
|
||||
|
||||
/// The binding the `data` slot declares.
|
||||
#[serde(default = "default_data_binding")]
|
||||
pub data_binding: String,
|
||||
|
||||
/// How much of the answer side to include.
|
||||
#[serde(default = "default_reveal")]
|
||||
pub reveal: Reveal,
|
||||
|
||||
/// How option labels are generated.
|
||||
#[serde(default = "default_letters")]
|
||||
pub letters: LetterStyle,
|
||||
|
||||
/// How markup is emitted.
|
||||
#[serde(default = "default_content")]
|
||||
pub content: ContentMode,
|
||||
|
||||
/// How shared stimuli are handled.
|
||||
#[serde(default = "default_stimulus")]
|
||||
pub stimulus: StimulusMode,
|
||||
|
||||
/// Which optional fields to include.
|
||||
#[serde(default)]
|
||||
pub fields: Fields,
|
||||
|
||||
/// Whether questions carry the number recorded in the assessment record
|
||||
/// rather than their printed position.
|
||||
///
|
||||
/// Keep this on. The recorded number is the join key to every grading export
|
||||
/// and response row; renumbering after a drop breaks that join silently, and
|
||||
/// the symptom is item statistics attributed to the wrong question.
|
||||
#[serde(default = "yes")]
|
||||
pub number_from_record: bool,
|
||||
|
||||
/// Anything else you want the template to see, carried through untouched.
|
||||
///
|
||||
/// This is the escape hatch that keeps the crate out of your layout
|
||||
/// decisions. Tier colours, a `show-solutions` flag, a font stack, a watermark
|
||||
/// string: put it here and read it from `extra` in the template.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub extra: BTreeMap<String, serde_yaml_ng::Value>,
|
||||
}
|
||||
|
||||
impl RenderConfig {
|
||||
/// The built-in configuration for a variant.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `variant` - which document.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A configuration matching what the bundled template for that variant
|
||||
/// expects.
|
||||
pub fn for_variant(variant: Variant) -> RenderConfig {
|
||||
let mut config = RenderConfig {
|
||||
template: None,
|
||||
question_fn: default_question_fn(),
|
||||
meta_binding: default_meta_binding(),
|
||||
data_binding: default_data_binding(),
|
||||
reveal: Reveal::Nothing,
|
||||
letters: LetterStyle::Upper,
|
||||
content: ContentMode::Content,
|
||||
stimulus: StimulusMode::Inline,
|
||||
fields: Fields::default(),
|
||||
number_from_record: true,
|
||||
extra: BTreeMap::new(),
|
||||
};
|
||||
match variant {
|
||||
Variant::Exam => {
|
||||
// The paper must not carry the key in any form.
|
||||
config.reveal = Reveal::Nothing;
|
||||
config.fields.uid = false;
|
||||
config.fields.source_letters = false;
|
||||
config.fields.objectives = false;
|
||||
}
|
||||
Variant::Key => {
|
||||
config.reveal = Reveal::Everything;
|
||||
config.stimulus = StimulusMode::Omit;
|
||||
}
|
||||
Variant::AnswerSheet => {
|
||||
config.reveal = Reveal::Nothing;
|
||||
config.stimulus = StimulusMode::Omit;
|
||||
config.fields = Fields {
|
||||
uid: false,
|
||||
title: false,
|
||||
points: true,
|
||||
level: false,
|
||||
objectives: false,
|
||||
topics: false,
|
||||
assets: false,
|
||||
source_letters: false,
|
||||
design: false,
|
||||
calibration: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
config
|
||||
}
|
||||
|
||||
/// Renders the configuration as YAML.
|
||||
///
|
||||
/// For `coursebank template config --resolved`, which is the answer to "which
|
||||
/// layer won?" — a question that is otherwise answered by reading three files
|
||||
/// and guessing.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// YAML text.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::Other`](crate::error::Error::Other) if serialization
|
||||
/// fails.
|
||||
pub fn to_yaml(&self) -> Result<String> {
|
||||
serde_yaml_ng::to_string(self).map_err(Error::other)
|
||||
}
|
||||
}
|
||||
|
||||
/// The file a course's Typst configuration lives in, under `templates/`.
|
||||
pub const CONFIG_FILE: &str = "typst.yaml";
|
||||
|
||||
/// A course's Typst export configuration.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ConfigFile {
|
||||
/// Schema version this file targets.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub schema_version: Option<String>,
|
||||
/// Overrides applied to every variant.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub defaults: Option<Overrides>,
|
||||
/// Overrides applied to one variant, on top of `defaults`.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub variants: BTreeMap<Variant, Overrides>,
|
||||
}
|
||||
|
||||
impl ConfigFile {
|
||||
/// Resolves the configuration for one variant.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `variant` - which document.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The built-in defaults with the file's `defaults` and then its
|
||||
/// variant-specific block applied.
|
||||
pub fn resolve(&self, variant: Variant) -> RenderConfig {
|
||||
let mut config = RenderConfig::for_variant(variant);
|
||||
if let Some(defaults) = &self.defaults {
|
||||
defaults.apply(&mut config);
|
||||
}
|
||||
if let Some(specific) = self.variants.get(&variant) {
|
||||
specific.apply(&mut config);
|
||||
}
|
||||
config
|
||||
}
|
||||
}
|
||||
|
||||
/// A partial [`RenderConfig`]: every field optional, so a config file can say one
|
||||
/// thing without restating the rest.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Overrides {
|
||||
/// See [`RenderConfig::template`].
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub template: Option<PathBuf>,
|
||||
/// See [`RenderConfig::question_fn`].
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub question_fn: Option<String>,
|
||||
/// See [`RenderConfig::meta_binding`].
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub meta_binding: Option<String>,
|
||||
/// See [`RenderConfig::data_binding`].
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub data_binding: Option<String>,
|
||||
/// See [`RenderConfig::reveal`].
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reveal: Option<Reveal>,
|
||||
/// See [`RenderConfig::letters`].
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub letters: Option<LetterStyle>,
|
||||
/// See [`RenderConfig::content`].
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub content: Option<ContentMode>,
|
||||
/// See [`RenderConfig::stimulus`].
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub stimulus: Option<StimulusMode>,
|
||||
/// See [`RenderConfig::fields`].
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub fields: Option<Fields>,
|
||||
/// See [`RenderConfig::number_from_record`].
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub number_from_record: Option<bool>,
|
||||
/// Merged key by key into [`RenderConfig::extra`] rather than replacing it, so
|
||||
/// a variant can add one flag without repeating the shared block.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub extra: BTreeMap<String, serde_yaml_ng::Value>,
|
||||
}
|
||||
|
||||
impl Overrides {
|
||||
/// Applies these overrides in place.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `config` - the configuration to modify.
|
||||
pub fn apply(&self, config: &mut RenderConfig) {
|
||||
if let Some(v) = &self.template {
|
||||
config.template = Some(v.clone());
|
||||
}
|
||||
if let Some(v) = &self.question_fn {
|
||||
config.question_fn = v.clone();
|
||||
}
|
||||
if let Some(v) = &self.meta_binding {
|
||||
config.meta_binding = v.clone();
|
||||
}
|
||||
if let Some(v) = &self.data_binding {
|
||||
config.data_binding = v.clone();
|
||||
}
|
||||
if let Some(v) = self.reveal {
|
||||
config.reveal = v;
|
||||
}
|
||||
if let Some(v) = self.letters {
|
||||
config.letters = v;
|
||||
}
|
||||
if let Some(v) = self.content {
|
||||
config.content = v;
|
||||
}
|
||||
if let Some(v) = self.stimulus {
|
||||
config.stimulus = v;
|
||||
}
|
||||
if let Some(v) = &self.fields {
|
||||
config.fields = v.clone();
|
||||
}
|
||||
if let Some(v) = self.number_from_record {
|
||||
config.number_from_record = v;
|
||||
}
|
||||
for (key, value) in &self.extra {
|
||||
config.extra.insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The commented starter config written by `coursebank template config`.
|
||||
///
|
||||
/// Written as text rather than serialized from [`ConfigFile`] because the comments
|
||||
/// are the useful part, and a serializer drops them.
|
||||
pub const CONFIG_TEMPLATE: &str = r#"# Typst export configuration.
|
||||
#
|
||||
# Layers, each overriding the last:
|
||||
# 1. the built-in defaults for the variant
|
||||
# 2. `defaults:` below
|
||||
# 3. `variants:` below
|
||||
#
|
||||
# `coursebank template config --resolved` prints what a variant actually ends up
|
||||
# with, which is the quickest way to check whether a key landed where you meant.
|
||||
schema_version: "1.0"
|
||||
|
||||
defaults:
|
||||
# The Typst function the `questions` slot calls once per item, with one
|
||||
# dictionary argument. Your template defines it.
|
||||
question_fn: render-question
|
||||
|
||||
# `content` emits stems as `[...]`, which Typst parses directly.
|
||||
# `str` emits them as strings for a template that calls `eval`, and is what you
|
||||
# want if the same payload is also read as JSON.
|
||||
content: content
|
||||
|
||||
# upper | lower | numeric | roman | nothing
|
||||
letters: upper
|
||||
|
||||
# Anything here is passed through untouched and shows up as `extra` in the
|
||||
# payload. This is where layout decisions belong.
|
||||
#
|
||||
# Note the spelling shift: keys above are coursebank's and are snake_case like
|
||||
# every other coursebank YAML file, but keys under `extra` are yours and reach
|
||||
# Typst verbatim, so they use Typst's hyphens.
|
||||
extra:
|
||||
accent: '#017ab9'
|
||||
font: 'Libertinus Serif'
|
||||
|
||||
variants:
|
||||
exam:
|
||||
# Leave this alone unless you are certain. `nothing` is what keeps the answer
|
||||
# out of the student's copy; a template cannot leak a field it was never given.
|
||||
reveal: nothing
|
||||
extra:
|
||||
show-solutions: false
|
||||
|
||||
key:
|
||||
# nothing | key | everything
|
||||
reveal: everything
|
||||
extra:
|
||||
show-solutions: true
|
||||
|
||||
answer-sheet:
|
||||
reveal: nothing
|
||||
"#;
|
||||
|
||||
/// Serde default: `true`.
|
||||
fn yes() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Serde default: `false`.
|
||||
fn no() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Serde default for [`RenderConfig::question_fn`].
|
||||
fn default_question_fn() -> String {
|
||||
"render-question".to_string()
|
||||
}
|
||||
|
||||
/// Serde default for [`RenderConfig::meta_binding`].
|
||||
fn default_meta_binding() -> String {
|
||||
"cb-meta".to_string()
|
||||
}
|
||||
|
||||
/// Serde default for [`RenderConfig::data_binding`].
|
||||
fn default_data_binding() -> String {
|
||||
"cb-data".to_string()
|
||||
}
|
||||
|
||||
/// Serde default for [`RenderConfig::reveal`].
|
||||
fn default_reveal() -> Reveal {
|
||||
Reveal::Nothing
|
||||
}
|
||||
|
||||
/// Serde default for [`RenderConfig::letters`].
|
||||
fn default_letters() -> LetterStyle {
|
||||
LetterStyle::Upper
|
||||
}
|
||||
|
||||
/// Serde default for [`RenderConfig::content`].
|
||||
fn default_content() -> ContentMode {
|
||||
ContentMode::Content
|
||||
}
|
||||
|
||||
/// Serde default for [`RenderConfig::stimulus`].
|
||||
fn default_stimulus() -> StimulusMode {
|
||||
StimulusMode::Inline
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn variant_tokens_round_trip() {
|
||||
for variant in Variant::ALL {
|
||||
assert_eq!(Variant::parse(variant.as_str()).unwrap(), variant);
|
||||
}
|
||||
// Underscores are tolerated because people type them.
|
||||
assert_eq!(
|
||||
Variant::parse("answer_sheet").unwrap(),
|
||||
Variant::AnswerSheet
|
||||
);
|
||||
assert!(Variant::parse("bubbles").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_exam_variant_never_reveals_the_key_by_default() {
|
||||
// This is the one default in this module that is a correctness property
|
||||
// rather than a preference.
|
||||
let config = RenderConfig::for_variant(Variant::Exam);
|
||||
assert_eq!(config.reveal, Reveal::Nothing);
|
||||
assert!(!config.reveal.shows_key());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn letters_are_generated_per_style() {
|
||||
assert_eq!(LetterStyle::Upper.label(0), "A");
|
||||
assert_eq!(LetterStyle::Upper.label(3), "D");
|
||||
assert_eq!(LetterStyle::Lower.label(1), "b");
|
||||
assert_eq!(LetterStyle::Numeric.label(4), "5");
|
||||
assert_eq!(LetterStyle::Roman.label(3), "iv");
|
||||
assert_eq!(LetterStyle::Nothing.label(2), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn letters_past_z_do_not_run_off_the_alphabet() {
|
||||
// `b'A' + 26` is `[`. Anything that produced that would be a silent
|
||||
// corruption of an option label rather than an error.
|
||||
assert_eq!(LetterStyle::Upper.label(26), "AA");
|
||||
assert_eq!(LetterStyle::Upper.label(25), "Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_layers_override_in_order() {
|
||||
let file: ConfigFile = serde_yaml_ng::from_str(
|
||||
"defaults:\n letters: lower\n extra:\n a: 1\nvariants:\n key:\n letters: \
|
||||
numeric\n extra:\n b: 2\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let exam = file.resolve(Variant::Exam);
|
||||
assert_eq!(exam.letters, LetterStyle::Lower);
|
||||
|
||||
let key = file.resolve(Variant::Key);
|
||||
assert_eq!(key.letters, LetterStyle::Numeric);
|
||||
// `extra` merges rather than replacing, so `a` survives.
|
||||
assert!(key.extra.contains_key("a"));
|
||||
assert!(key.extra.contains_key("b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_config_file_changes_nothing() {
|
||||
let file = ConfigFile::default();
|
||||
for variant in Variant::ALL {
|
||||
assert_eq!(file.resolve(variant), RenderConfig::for_variant(variant));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_starter_config_parses_and_keeps_the_exam_closed() {
|
||||
let file: ConfigFile = serde_yaml_ng::from_str(CONFIG_TEMPLATE).unwrap();
|
||||
assert_eq!(file.resolve(Variant::Exam).reveal, Reveal::Nothing);
|
||||
assert_eq!(file.resolve(Variant::Key).reveal, Reveal::Everything);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,743 @@
|
||||
//! Loading a Typst file and splicing generated data into it.
|
||||
//!
|
||||
//! The previous version of this module built a document with `format!`. That made
|
||||
//! every layout decision a code change, which is the wrong place for a decision
|
||||
//! about where the points label sits. So the tool no longer writes documents; it
|
||||
//! writes *data into* a document you own.
|
||||
//!
|
||||
//! ## Markers
|
||||
//!
|
||||
//! A template marks its injection points with Typst line comments, which means a
|
||||
//! template is a valid `.typ` file that compiles on its own and can be styled
|
||||
//! without this tool in the loop:
|
||||
//!
|
||||
//! ```typst
|
||||
//! // coursebank:begin questions
|
||||
//! #render-question((number: 1, stem: [Sample.], options: ()))
|
||||
//! // coursebank:end questions
|
||||
//! ```
|
||||
//!
|
||||
//! Everything between the `begin` and `end` lines is replaced; the marker lines
|
||||
//! themselves survive. That has two consequences worth stating plainly:
|
||||
//!
|
||||
//! * The bundled templates ship with sample data inside their regions, so
|
||||
//! `typst compile templates/exam.typ` works before any export has happened.
|
||||
//! * An exported document is itself a valid template. Re-exporting into a file you
|
||||
//! have since restyled replaces the questions and leaves your edits alone, which
|
||||
//! is the difference between a generator you can use twice and one you copy out
|
||||
//! of once.
|
||||
//!
|
||||
//! A bare `// coursebank:questions` with no region also works. It is rewritten
|
||||
//! into a region on output, so the second export behaves like every subsequent one.
|
||||
//!
|
||||
//! ## Slots
|
||||
//!
|
||||
//! | Slot | Injected |
|
||||
//! |:--|:--|
|
||||
//! | `meta` | `#let cb-meta = (...)` — course, assessment, form, totals |
|
||||
//! | `questions` | one `#render-question((...))` call per printed item |
|
||||
//! | `data` | `#let cb-data = (...)` — metadata and questions together |
|
||||
//!
|
||||
//! `questions` unrolls the loop with the record's own numbering. `data` hands you
|
||||
//! the array and gets out of the way. Templates are free to use either, both, or
|
||||
//! neither; only slots the template actually contains are rendered, so nothing
|
||||
//! costs anything until it is asked for.
|
||||
//!
|
||||
//! ## Lookup order
|
||||
//!
|
||||
//! 1. an explicit `--template` path, or `template:` in the render config
|
||||
//! 2. `templates/<assessment-id>-<variant>.typ`, for a one-off layout
|
||||
//! 3. `templates/<variant>.typ`, the course's own default
|
||||
//! 4. the template compiled into this binary
|
||||
//!
|
||||
//! `coursebank template dump` writes step 4 into step 3 so that customizing means
|
||||
//! editing a file rather than reading this documentation.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::course::Layout;
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
use super::config::Variant;
|
||||
|
||||
/// The prefix every marker comment carries.
|
||||
const MARKER: &str = "coursebank:";
|
||||
|
||||
/// The bundled exam paper template.
|
||||
const EMBEDDED_EXAM: &str = include_str!("templates/exam.typ");
|
||||
|
||||
/// The bundled answer key template.
|
||||
const EMBEDDED_KEY: &str = include_str!("templates/key.typ");
|
||||
|
||||
/// The bundled answer sheet template.
|
||||
const EMBEDDED_ANSWER_SHEET: &str = include_str!("templates/answer-sheet.typ");
|
||||
|
||||
/// An injection point a template can declare.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum Slot {
|
||||
/// Course, assessment, form, and totals, as a `#let` binding.
|
||||
Meta,
|
||||
/// One call per printed item.
|
||||
Questions,
|
||||
/// Metadata and questions together, as a `#let` binding.
|
||||
Data,
|
||||
}
|
||||
|
||||
impl Slot {
|
||||
/// Every slot.
|
||||
pub const ALL: [Slot; 3] = [Slot::Meta, Slot::Questions, Slot::Data];
|
||||
|
||||
/// The name used in a marker comment.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Slot::Meta => "meta",
|
||||
Slot::Questions => "questions",
|
||||
Slot::Data => "data",
|
||||
}
|
||||
}
|
||||
|
||||
/// The slot for a marker name.
|
||||
fn parse(s: &str) -> Option<Slot> {
|
||||
Slot::ALL.iter().copied().find(|slot| slot.as_str() == s)
|
||||
}
|
||||
|
||||
/// A comma-separated list of every slot name, for error messages.
|
||||
fn names() -> String {
|
||||
Slot::ALL
|
||||
.iter()
|
||||
.map(|s| s.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a template came from.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Origin {
|
||||
/// Compiled into the binary.
|
||||
Embedded,
|
||||
/// Read from disk.
|
||||
File(PathBuf),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Origin {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Origin::Embedded => f.write_str("built-in"),
|
||||
Origin::File(path) => write!(f, "{}", path.display()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A loaded template, with its markers already located.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Template {
|
||||
/// Which document this template produces.
|
||||
pub variant: Variant,
|
||||
/// Where it was loaded from.
|
||||
pub origin: Origin,
|
||||
/// The full source.
|
||||
pub source: String,
|
||||
/// The regions found, in the order they appear.
|
||||
regions: Vec<Region>,
|
||||
}
|
||||
|
||||
/// One located marker, as a half-open line range to replace.
|
||||
#[derive(Debug, Clone)]
|
||||
struct Region {
|
||||
/// Which slot.
|
||||
slot: Slot,
|
||||
/// The marker line's leading whitespace, reapplied to every injected line so
|
||||
/// data nested inside a Typst block stays readable.
|
||||
indent: String,
|
||||
/// First line index of the marker, which is the `begin` line for a region.
|
||||
start: usize,
|
||||
/// One past the `end` line index, or one past a bare point marker.
|
||||
end: usize,
|
||||
}
|
||||
|
||||
/// The source of the template compiled in for a variant.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `variant` - which document.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The bundled template source.
|
||||
pub fn embedded(variant: Variant) -> &'static str {
|
||||
match variant {
|
||||
Variant::Exam => EMBEDDED_EXAM,
|
||||
Variant::Key => EMBEDDED_KEY,
|
||||
Variant::AnswerSheet => EMBEDDED_ANSWER_SHEET,
|
||||
}
|
||||
}
|
||||
|
||||
/// The directory holding a course's template overrides.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `layout` - the course layout.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The `templates/` directory, which need not exist.
|
||||
pub fn dir(layout: &Layout) -> PathBuf {
|
||||
layout.templates()
|
||||
}
|
||||
|
||||
/// The paths that are consulted for a variant, in order.
|
||||
///
|
||||
/// Exposed so `coursebank template list` can show where a template would be found
|
||||
/// and why, rather than leaving the lookup order to be inferred.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `layout` - the course layout.
|
||||
/// * `variant` - which document.
|
||||
/// * `assessment_id` - the assessment being exported, if one is in hand.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Candidate paths, most specific first.
|
||||
pub fn candidates(layout: &Layout, variant: Variant, assessment_id: Option<&str>) -> Vec<PathBuf> {
|
||||
let base = dir(layout);
|
||||
let mut paths = Vec::new();
|
||||
if let Some(id) = assessment_id {
|
||||
paths.push(base.join(format!("{id}-{}", variant.template_file())));
|
||||
}
|
||||
paths.push(base.join(variant.template_file()));
|
||||
paths
|
||||
}
|
||||
|
||||
/// Loads the template for a variant.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `layout` - the course layout.
|
||||
/// * `variant` - which document.
|
||||
/// * `assessment_id` - the assessment being exported, if one is in hand.
|
||||
/// * `explicit` - a path that overrides the lookup entirely.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The loaded template.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::Io`] when an explicitly requested template cannot be read, and
|
||||
/// [`Error::Invalid`] when the template's markers are malformed. A missing file in
|
||||
/// the lookup chain is not an error; it just falls through to the next candidate.
|
||||
pub fn load(
|
||||
layout: &Layout,
|
||||
variant: Variant,
|
||||
assessment_id: Option<&str>,
|
||||
explicit: Option<&Path>,
|
||||
) -> Result<Template> {
|
||||
if let Some(path) = explicit {
|
||||
let source = std::fs::read_to_string(path).map_err(|e| Error::io(path, e))?;
|
||||
return Template::parse(variant, Origin::File(path.to_path_buf()), source);
|
||||
}
|
||||
|
||||
for path in candidates(layout, variant, assessment_id) {
|
||||
if path.is_file() {
|
||||
let source = std::fs::read_to_string(&path).map_err(|e| Error::io(&path, e))?;
|
||||
return Template::parse(variant, Origin::File(path), source);
|
||||
}
|
||||
}
|
||||
|
||||
Template::parse(variant, Origin::Embedded, embedded(variant).to_string())
|
||||
}
|
||||
|
||||
impl Template {
|
||||
/// Parses a template, locating its markers.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `variant` - which document.
|
||||
/// * `origin` - where the source came from.
|
||||
/// * `source` - the template source.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The parsed template.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::Invalid`] listing every marker problem at once: an unknown
|
||||
/// slot name, a `begin` with no `end`, an `end` with no `begin`, a nested
|
||||
/// region, or the same slot claimed twice.
|
||||
pub fn parse(variant: Variant, origin: Origin, source: String) -> Result<Template> {
|
||||
let lines: Vec<&str> = source.lines().collect();
|
||||
let mut regions: Vec<Region> = Vec::new();
|
||||
let mut issues: Vec<String> = Vec::new();
|
||||
let mut open: Option<(Slot, String, usize)> = None;
|
||||
|
||||
for (index, line) in lines.iter().enumerate() {
|
||||
let Some(marker) = parse_marker(line) else {
|
||||
continue;
|
||||
};
|
||||
let human = index + 1;
|
||||
|
||||
match marker.kind {
|
||||
MarkerKind::Begin => {
|
||||
if let Some((slot, _, at)) = &open {
|
||||
issues.push(format!(
|
||||
"line {human}: `begin {}` opens inside the region `{}` opened on line \
|
||||
{}; regions cannot nest",
|
||||
marker.name,
|
||||
slot.as_str(),
|
||||
at + 1
|
||||
));
|
||||
continue;
|
||||
}
|
||||
match Slot::parse(&marker.name) {
|
||||
Some(slot) => open = Some((slot, marker.indent, index)),
|
||||
None => issues.push(unknown_slot(human, &marker.name)),
|
||||
}
|
||||
}
|
||||
MarkerKind::End => match open.take() {
|
||||
Some((slot, indent, start)) => {
|
||||
if slot.as_str() != marker.name {
|
||||
issues.push(format!(
|
||||
"line {human}: `end {}` closes the region `{}` opened on line \
|
||||
{}",
|
||||
marker.name,
|
||||
slot.as_str(),
|
||||
start + 1
|
||||
));
|
||||
}
|
||||
regions.push(Region {
|
||||
slot,
|
||||
indent,
|
||||
start,
|
||||
end: index + 1,
|
||||
});
|
||||
}
|
||||
None => issues.push(format!(
|
||||
"line {human}: `end {}` has no matching `begin`",
|
||||
marker.name
|
||||
)),
|
||||
},
|
||||
MarkerKind::Point => {
|
||||
if open.is_some() {
|
||||
// A point marker inside a region would be overwritten by
|
||||
// the region's own injection, so it is a mistake worth
|
||||
// naming rather than silently dropping.
|
||||
issues.push(format!(
|
||||
"line {human}: the marker `{}` sits inside an open region and would be \
|
||||
overwritten",
|
||||
marker.name
|
||||
));
|
||||
continue;
|
||||
}
|
||||
match Slot::parse(&marker.name) {
|
||||
Some(slot) => regions.push(Region {
|
||||
slot,
|
||||
indent: marker.indent,
|
||||
start: index,
|
||||
end: index + 1,
|
||||
}),
|
||||
None => issues.push(unknown_slot(human, &marker.name)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((slot, _, start)) = open {
|
||||
issues.push(format!(
|
||||
"line {}: the region `{}` is never closed; add `// coursebank:end {}`",
|
||||
start + 1,
|
||||
slot.as_str(),
|
||||
slot.as_str()
|
||||
));
|
||||
}
|
||||
|
||||
for slot in Slot::ALL {
|
||||
let count = regions.iter().filter(|r| r.slot == slot).count();
|
||||
if count > 1 {
|
||||
issues.push(format!(
|
||||
"the slot `{}` appears {count} times; each slot may be filled once",
|
||||
slot.as_str()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if !issues.is_empty() {
|
||||
issues.insert(0, format!("in the Typst template {origin}:"));
|
||||
return Err(Error::Invalid(issues));
|
||||
}
|
||||
|
||||
Ok(Template {
|
||||
variant,
|
||||
origin,
|
||||
source,
|
||||
regions,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the template asks for a slot.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `slot` - the slot to look for.
|
||||
pub fn wants(&self, slot: Slot) -> bool {
|
||||
self.regions.iter().any(|r| r.slot == slot)
|
||||
}
|
||||
|
||||
/// The slots this template declares, in the order they appear.
|
||||
pub fn slots(&self) -> Vec<Slot> {
|
||||
self.regions.iter().map(|r| r.slot).collect()
|
||||
}
|
||||
|
||||
/// Whether the template declares no markers at all.
|
||||
pub fn is_inert(&self) -> bool {
|
||||
self.regions.is_empty()
|
||||
}
|
||||
|
||||
/// Renders the template with the given slot bodies.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `bodies` - the Typst source to inject, by slot. A slot the template does
|
||||
/// not declare is ignored; a slot the template declares but that has no body
|
||||
/// is emitted as an empty region.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The finished document. Every injected region is delimited by `begin`/`end`
|
||||
/// markers, including regions that came from a bare point marker, so the
|
||||
/// output can be used as the template for the next export.
|
||||
pub fn render(&self, bodies: &[(Slot, String)]) -> String {
|
||||
let lines: Vec<&str> = self.source.lines().collect();
|
||||
let mut out = String::with_capacity(self.source.len() + 4096);
|
||||
let mut cursor = 0usize;
|
||||
|
||||
// `parse` produced regions in source order and rejected overlaps, so a
|
||||
// single forward pass is enough.
|
||||
for region in &self.regions {
|
||||
for line in &lines[cursor..region.start] {
|
||||
out.push_str(line);
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
let body = bodies
|
||||
.iter()
|
||||
.find(|(slot, _)| *slot == region.slot)
|
||||
.map(|(_, body)| body.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
let name = region.slot.as_str();
|
||||
let indent = region.indent.as_str();
|
||||
|
||||
out.push_str(indent);
|
||||
out.push_str("// ");
|
||||
out.push_str(MARKER);
|
||||
out.push_str("begin ");
|
||||
out.push_str(name);
|
||||
out.push('\n');
|
||||
|
||||
for line in body.lines() {
|
||||
if line.trim().is_empty() {
|
||||
out.push('\n');
|
||||
} else {
|
||||
out.push_str(indent);
|
||||
out.push_str(line);
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
out.push_str(indent);
|
||||
out.push_str("// ");
|
||||
out.push_str(MARKER);
|
||||
out.push_str("end ");
|
||||
out.push_str(name);
|
||||
out.push('\n');
|
||||
|
||||
// Everything from the opening marker through the closing one has now
|
||||
// been rewritten, so resume after it.
|
||||
cursor = region.end;
|
||||
}
|
||||
|
||||
for line in &lines[cursor..] {
|
||||
out.push_str(line);
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes the bundled templates into a directory.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `dir` - the destination directory, created if absent.
|
||||
/// * `variants` - which templates to write.
|
||||
/// * `force` - whether to overwrite files that already exist.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The paths written, and the paths skipped because they already existed.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::Io`] when the directory or a file cannot be written.
|
||||
pub fn dump(dir: &Path, variants: &[Variant], force: bool) -> Result<(Vec<PathBuf>, Vec<PathBuf>)> {
|
||||
std::fs::create_dir_all(dir).map_err(|e| Error::io(dir, e))?;
|
||||
let mut written = Vec::new();
|
||||
let mut skipped = Vec::new();
|
||||
for variant in variants {
|
||||
let path = dir.join(variant.template_file());
|
||||
if path.exists() && !force {
|
||||
// These are hand-edited files. Clobbering one is the sort of thing you
|
||||
// discover after you have already lost the edit.
|
||||
skipped.push(path);
|
||||
continue;
|
||||
}
|
||||
crate::yaml::write_text(&path, embedded(*variant))?;
|
||||
written.push(path);
|
||||
}
|
||||
Ok((written, skipped))
|
||||
}
|
||||
|
||||
/// A marker comment, parsed.
|
||||
struct Marker {
|
||||
kind: MarkerKind,
|
||||
name: String,
|
||||
indent: String,
|
||||
}
|
||||
|
||||
/// What a marker comment does.
|
||||
enum MarkerKind {
|
||||
/// Opens a replaceable region.
|
||||
Begin,
|
||||
/// Closes one.
|
||||
End,
|
||||
/// A standalone insertion point.
|
||||
Point,
|
||||
}
|
||||
|
||||
/// Parses one line as a marker comment, if it is one.
|
||||
///
|
||||
/// Recognized forms, with any leading whitespace and any run of `/` accepted:
|
||||
///
|
||||
/// ```text
|
||||
/// // coursebank:questions
|
||||
/// // coursebank:begin questions
|
||||
/// // coursebank:end questions
|
||||
/// ```
|
||||
///
|
||||
/// `begin`/`end` may also be spelled with a colon (`coursebank:begin:questions`),
|
||||
/// because that is how people guess it.
|
||||
fn parse_marker(line: &str) -> Option<Marker> {
|
||||
let indent: String = line.chars().take_while(|c| c.is_whitespace()).collect();
|
||||
let rest = line[indent.len()..].trim_end();
|
||||
|
||||
let rest = rest
|
||||
.strip_prefix("//")?
|
||||
.trim_start_matches('/')
|
||||
.trim_start();
|
||||
let rest = rest.strip_prefix(MARKER)?.trim();
|
||||
if rest.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let (kind, name) = if let Some(name) = strip_word(rest, "begin") {
|
||||
(MarkerKind::Begin, name)
|
||||
} else if let Some(name) = strip_word(rest, "end") {
|
||||
(MarkerKind::End, name)
|
||||
} else {
|
||||
(MarkerKind::Point, rest)
|
||||
};
|
||||
|
||||
Some(Marker {
|
||||
kind,
|
||||
name: name.trim().to_ascii_lowercase(),
|
||||
indent,
|
||||
})
|
||||
}
|
||||
|
||||
/// Strips a leading `begin`/`end` keyword, separated by whitespace or a colon.
|
||||
fn strip_word<'a>(s: &'a str, word: &str) -> Option<&'a str> {
|
||||
let rest = s.strip_prefix(word)?;
|
||||
match rest.chars().next() {
|
||||
Some(c) if c.is_whitespace() || c == ':' => Some(rest[c.len_utf8()..].trim_start()),
|
||||
// `begin` alone, with no slot named.
|
||||
None => Some(""),
|
||||
// `beginning`, which is a slot name that happens to start with `begin`.
|
||||
Some(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The message for a marker naming a slot that does not exist.
|
||||
fn unknown_slot(line: usize, name: &str) -> String {
|
||||
format!(
|
||||
"line {line}: unknown slot `{name}`; expected one of {}",
|
||||
Slot::names()
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn parse(source: &str) -> Result<Template> {
|
||||
Template::parse(Variant::Exam, Origin::Embedded, source.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_point_marker_is_replaced_and_becomes_a_region() {
|
||||
// The second export has to behave like every one after it, so a point
|
||||
// marker is rewritten into a region on the way out.
|
||||
let template = parse("before\n// coursebank:questions\nafter\n").unwrap();
|
||||
let out = template.render(&[(Slot::Questions, "#q(1)".to_string())]);
|
||||
assert!(out.contains("before\n"));
|
||||
assert!(out.contains("// coursebank:begin questions\n#q(1)\n"));
|
||||
assert!(out.contains("// coursebank:end questions\n"));
|
||||
assert!(out.contains("after\n"));
|
||||
assert!(!out.contains("#q(1)\n#q(1)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_region_replaces_its_body_and_keeps_its_markers() {
|
||||
let template = parse(
|
||||
"head\n// coursebank:begin questions\nstale sample data\nmore stale\n// \
|
||||
coursebank:end questions\ntail\n",
|
||||
)
|
||||
.unwrap();
|
||||
let out = template.render(&[(Slot::Questions, "#q(1)".to_string())]);
|
||||
assert!(!out.contains("stale"), "old body survived: {out}");
|
||||
assert!(out.contains("#q(1)"));
|
||||
assert!(out.contains("head\n"));
|
||||
assert!(out.contains("tail\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rendering_is_idempotent() {
|
||||
// Exporting into a file that was itself exported must replace the
|
||||
// questions and leave the surrounding edits alone. This is the property
|
||||
// that makes "restyle the output, then re-export" a workable habit.
|
||||
let template = parse("// coursebank:questions\n").unwrap();
|
||||
let first = template.render(&[(Slot::Questions, "#q(1)".to_string())]);
|
||||
let again = parse(&first).unwrap();
|
||||
let second = again.render(&[(Slot::Questions, "#q(2)".to_string())]);
|
||||
assert!(second.contains("#q(2)"));
|
||||
assert!(!second.contains("#q(1)"));
|
||||
assert_eq!(second.matches("coursebank:begin questions").count(), 1);
|
||||
assert_eq!(second.matches("coursebank:end questions").count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indentation_is_reapplied_to_injected_lines() {
|
||||
let template = parse("#block[\n // coursebank:questions\n]\n").unwrap();
|
||||
let out = template.render(&[(Slot::Questions, "#q(\n 1,\n)".to_string())]);
|
||||
assert!(out.contains(" // coursebank:begin questions"), "{out}");
|
||||
assert!(out.contains(" #q("), "{out}");
|
||||
assert!(out.contains(" 1,"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_declared_slots_are_reported() {
|
||||
let template = parse("// coursebank:meta\n// coursebank:questions\n").unwrap();
|
||||
assert!(template.wants(Slot::Meta));
|
||||
assert!(template.wants(Slot::Questions));
|
||||
assert!(!template.wants(Slot::Data));
|
||||
assert_eq!(template.slots(), vec![Slot::Meta, Slot::Questions]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_template_with_no_markers_is_reported_as_inert() {
|
||||
// Not an error: someone may want a fully hand-written paper. But the CLI
|
||||
// warns, because silently writing a document with no questions in it is
|
||||
// not a good afternoon.
|
||||
let template = parse("#set page(paper: \"us-letter\")\n").unwrap();
|
||||
assert!(template.is_inert());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_slot_names_the_valid_ones() {
|
||||
let err = parse("// coursebank:qustions\n").unwrap_err();
|
||||
let message = err.to_string();
|
||||
assert!(message.contains("unknown slot `qustions`"), "{message}");
|
||||
assert!(message.contains("questions"), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unbalanced_regions_are_reported_with_line_numbers() {
|
||||
let err = parse("a\n// coursebank:begin questions\nb\n").unwrap_err();
|
||||
assert!(err.to_string().contains("never closed"));
|
||||
|
||||
let err = parse("// coursebank:end questions\n").unwrap_err();
|
||||
assert!(err.to_string().contains("no matching `begin`"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_duplicated_slot_is_an_error() {
|
||||
let err = parse("// coursebank:questions\n// coursebank:questions\n").unwrap_err();
|
||||
assert!(err.to_string().contains("appears 2 times"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mismatched_region_names_are_reported() {
|
||||
let err = parse("// coursebank:begin questions\n// coursebank:end meta\n").unwrap_err();
|
||||
assert!(err.to_string().contains("closes the region"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_problem_is_reported_in_one_pass() {
|
||||
let err = parse("// coursebank:nope\n// coursebank:end data\n").unwrap_err();
|
||||
let message = err.to_string();
|
||||
assert!(message.contains("unknown slot"), "{message}");
|
||||
assert!(message.contains("no matching `begin`"), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marker_spelling_is_forgiving() {
|
||||
assert!(parse("// coursebank:begin:questions\n// coursebank:end:questions\n").is_ok());
|
||||
assert!(parse(" // coursebank: questions\n").is_ok());
|
||||
assert!(parse("/// coursebank:questions\n").is_ok());
|
||||
assert!(parse("// coursebank:QUESTIONS\n").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_comments_are_left_alone() {
|
||||
assert!(parse("// nothing to see\n// coursebank\n")
|
||||
.unwrap()
|
||||
.is_inert());
|
||||
// A word that merely starts with `begin` is a slot name, not a keyword.
|
||||
let err = parse("// coursebank:beginning\n").unwrap_err();
|
||||
assert!(err.to_string().contains("unknown slot `beginning`"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_body_leaves_an_empty_region() {
|
||||
let template = parse("// coursebank:questions\n").unwrap();
|
||||
let out = template.render(&[]);
|
||||
assert!(out.contains("// coursebank:begin questions"));
|
||||
assert!(out.contains("// coursebank:end questions"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_bundled_template_parses_and_declares_slots() {
|
||||
for variant in Variant::ALL {
|
||||
let template =
|
||||
Template::parse(variant, Origin::Embedded, embedded(variant).to_string())
|
||||
.unwrap_or_else(|e| panic!("bundled {} template: {e}", variant.as_str()));
|
||||
assert!(
|
||||
!template.is_inert(),
|
||||
"the bundled {} template declares no slots",
|
||||
variant.as_str()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_lookup_order_is_most_specific_first() {
|
||||
let layout = Layout::new("/course");
|
||||
let paths = candidates(&layout, Variant::Key, Some("exam-2"));
|
||||
assert_eq!(paths.len(), 2);
|
||||
assert!(paths[0].ends_with("templates/exam-2-key.typ"));
|
||||
assert!(paths[1].ends_with("templates/key.typ"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// 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,204 @@
|
||||
// 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.
|
||||
//
|
||||
// // 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.
|
||||
//
|
||||
// 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.
|
||||
|
||||
|
||||
// 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
|
||||
|
||||
|
||||
// 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)
|
||||
|
||||
|
||||
// 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)]]
|
||||
}
|
||||
}
|
||||
|
||||
// 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) }
|
||||
}
|
||||
|
||||
#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.course.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,154 @@
|
||||
// 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)]
|
||||
}
|
||||
}
|
||||
],
|
||||
)
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
//! A small writer for Typst literals.
|
||||
//!
|
||||
//! Everything this crate injects into a template is data, not layout, and data
|
||||
//! has to arrive as syntax the Typst parser accepts. That is a narrow enough job
|
||||
//! to do by hand: eight value kinds, one recursive printer, and no dependency on
|
||||
//! a Typst implementation.
|
||||
//!
|
||||
//! Two details in here exist only because Typst's parenthesis syntax is
|
||||
//! overloaded, and both are the kind of thing that produces a confusing compile
|
||||
//! error rather than a clear one if you get them wrong:
|
||||
//!
|
||||
//! * An empty dictionary is `(:)`, not `()`, because `()` is the empty array.
|
||||
//! * A one-element array needs a trailing comma — `(1,)` — because `(1)` is just
|
||||
//! a parenthesized expression. Trailing commas are harmless everywhere else, so
|
||||
//! this printer always emits them.
|
||||
//!
|
||||
//! [`Value::Content`] is the reason this is not simply a JSON writer. Stems and
|
||||
//! option text are authored in a Typst subset, so they can be emitted as a
|
||||
//! content block that Typst parses directly. The alternative is a quoted string
|
||||
//! the template has to `eval`, which is what [`Value::Str`] gives you and what
|
||||
//! JSON interoperability requires. Both are supported because both are useful;
|
||||
//! see [`crate::typst::config::ContentMode`].
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
/// One value in an emitted Typst literal.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Value {
|
||||
/// Typst's `none`.
|
||||
None,
|
||||
/// A boolean.
|
||||
Bool(bool),
|
||||
/// An integer.
|
||||
Int(i64),
|
||||
/// A float. Non-finite values are written as `none`, since Typst has no
|
||||
/// literal for them and a NaN in a point total is a bug worth seeing.
|
||||
Float(f64),
|
||||
/// A quoted string.
|
||||
Str(String),
|
||||
/// Markup, emitted as a content block: `[...]`.
|
||||
Content(String),
|
||||
/// Verbatim Typst code, emitted with no quoting or escaping at all.
|
||||
Raw(String),
|
||||
/// An array.
|
||||
Array(Vec<Value>),
|
||||
/// A dictionary. Insertion order is preserved, because the output is meant to
|
||||
/// be read by a human comparing two exports.
|
||||
Dict(Vec<(String, Value)>),
|
||||
}
|
||||
|
||||
impl Value {
|
||||
/// A string value.
|
||||
pub fn str(s: impl Into<String>) -> Value {
|
||||
Value::Str(s.into())
|
||||
}
|
||||
|
||||
/// A content-block value.
|
||||
pub fn content(s: impl Into<String>) -> Value {
|
||||
Value::Content(s.into())
|
||||
}
|
||||
|
||||
/// An empty dictionary, ready for [`Value::insert`].
|
||||
pub fn dict() -> Value {
|
||||
Value::Dict(Vec::new())
|
||||
}
|
||||
|
||||
/// Adds a key to a dictionary, ignoring the call on any other kind.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `key` - the dictionary key.
|
||||
/// * `value` - the value to store.
|
||||
pub fn insert(&mut self, key: impl Into<String>, value: Value) {
|
||||
if let Value::Dict(entries) = self {
|
||||
entries.push((key.into(), value));
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds a key only when the value is `Some`, so an absent field is absent
|
||||
/// from the output rather than present and `none`.
|
||||
///
|
||||
/// This distinction carries real weight for the answer key: an exam paper
|
||||
/// whose payload omits `correct` cannot leak the key through a template that
|
||||
/// forgot to check a flag, whereas one that emits `correct: none` invites a
|
||||
/// template to treat the field as present.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `key` - the dictionary key.
|
||||
/// * `value` - the value, if there is one.
|
||||
pub fn insert_some(&mut self, key: impl Into<String>, value: Option<Value>) {
|
||||
if let Some(value) = value {
|
||||
self.insert(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this is a dictionary with no entries.
|
||||
pub fn is_empty_dict(&self) -> bool {
|
||||
matches!(self, Value::Dict(entries) if entries.is_empty())
|
||||
}
|
||||
|
||||
/// Renders the value as a Typst literal.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `indent` - how many levels of two-space indentation the value starts at.
|
||||
/// Nested values indent relative to this.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Typst source. Multi-line for non-empty arrays and dictionaries, single-line
|
||||
/// for everything else.
|
||||
pub fn to_typst(&self, indent: usize) -> String {
|
||||
let mut out = String::new();
|
||||
write_value(&mut out, self, indent);
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes one value at the given indentation level.
|
||||
fn write_value(out: &mut String, value: &Value, indent: usize) {
|
||||
match value {
|
||||
Value::None => out.push_str("none"),
|
||||
Value::Bool(true) => out.push_str("true"),
|
||||
Value::Bool(false) => out.push_str("false"),
|
||||
Value::Int(n) => {
|
||||
let _ = write!(out, "{n}");
|
||||
}
|
||||
Value::Float(x) => {
|
||||
if x.is_finite() {
|
||||
let _ = write!(out, "{x}");
|
||||
} else {
|
||||
out.push_str("none");
|
||||
}
|
||||
}
|
||||
Value::Str(s) => write_string(out, s),
|
||||
Value::Content(s) => {
|
||||
out.push('[');
|
||||
out.push_str(s);
|
||||
out.push(']');
|
||||
}
|
||||
Value::Raw(s) => out.push_str(s),
|
||||
Value::Array(items) => {
|
||||
if items.is_empty() {
|
||||
out.push_str("()");
|
||||
return;
|
||||
}
|
||||
out.push_str("(\n");
|
||||
for item in items {
|
||||
pad(out, indent + 1);
|
||||
write_value(out, item, indent + 1);
|
||||
out.push_str(",\n");
|
||||
}
|
||||
pad(out, indent);
|
||||
out.push(')');
|
||||
}
|
||||
Value::Dict(entries) => {
|
||||
if entries.is_empty() {
|
||||
// Not `()`, which is the empty array.
|
||||
out.push_str("(:)");
|
||||
return;
|
||||
}
|
||||
out.push_str("(\n");
|
||||
for (key, item) in entries {
|
||||
pad(out, indent + 1);
|
||||
write_key(out, key);
|
||||
out.push_str(": ");
|
||||
write_value(out, item, indent + 1);
|
||||
out.push_str(",\n");
|
||||
}
|
||||
pad(out, indent);
|
||||
out.push(')');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes `n` levels of two-space indentation.
|
||||
fn pad(out: &mut String, n: usize) {
|
||||
for _ in 0..n {
|
||||
out.push_str(" ");
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes a dictionary key, quoting it when it is not a bare identifier.
|
||||
fn write_key(out: &mut String, key: &str) {
|
||||
if is_identifier(key) {
|
||||
out.push_str(key);
|
||||
} else {
|
||||
write_string(out, key);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a string can be used as a bare Typst identifier.
|
||||
///
|
||||
/// Typst identifiers allow interior hyphens, which is why `render-question` is a
|
||||
/// legal function name and why this is not simply a Rust identifier check.
|
||||
fn is_identifier(s: &str) -> bool {
|
||||
let mut chars = s.chars();
|
||||
match chars.next() {
|
||||
Some(c) if c.is_alphabetic() || c == '_' => {}
|
||||
_ => return false,
|
||||
}
|
||||
chars.all(|c| c.is_alphanumeric() || c == '_' || c == '-')
|
||||
}
|
||||
|
||||
/// Writes a quoted, escaped Typst string.
|
||||
fn write_string(out: &mut String, s: &str) {
|
||||
out.push('"');
|
||||
for ch in s.chars() {
|
||||
match ch {
|
||||
'"' => out.push_str("\\\""),
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\r' => out.push_str("\\r"),
|
||||
'\t' => out.push_str("\\t"),
|
||||
_ => out.push(ch),
|
||||
}
|
||||
}
|
||||
out.push('"');
|
||||
}
|
||||
|
||||
/// Converts a parsed YAML value into a Typst value.
|
||||
///
|
||||
/// This is how arbitrary user configuration reaches a template: whatever is
|
||||
/// under `extra` in the render config is carried through unexamined, so a
|
||||
/// template can be given values this crate has never heard of.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `value` - the YAML value.
|
||||
/// * `strings_as_content` - when true, strings become content blocks rather than
|
||||
/// quoted strings.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The equivalent Typst value. YAML constructs with no Typst equivalent, such as
|
||||
/// a tagged node, become `none`.
|
||||
pub fn from_yaml(value: &serde_yaml_ng::Value, strings_as_content: bool) -> Value {
|
||||
match value {
|
||||
serde_yaml_ng::Value::Null => Value::None,
|
||||
serde_yaml_ng::Value::Bool(b) => Value::Bool(*b),
|
||||
serde_yaml_ng::Value::Number(n) => {
|
||||
if let Some(i) = n.as_i64() {
|
||||
Value::Int(i)
|
||||
} else if let Some(f) = n.as_f64() {
|
||||
Value::Float(f)
|
||||
} else {
|
||||
Value::None
|
||||
}
|
||||
}
|
||||
serde_yaml_ng::Value::String(s) => {
|
||||
if strings_as_content {
|
||||
Value::Content(s.clone())
|
||||
} else {
|
||||
Value::Str(s.clone())
|
||||
}
|
||||
}
|
||||
serde_yaml_ng::Value::Sequence(items) => Value::Array(
|
||||
items
|
||||
.iter()
|
||||
.map(|i| from_yaml(i, strings_as_content))
|
||||
.collect(),
|
||||
),
|
||||
serde_yaml_ng::Value::Mapping(map) => {
|
||||
let mut entries = Vec::new();
|
||||
for (key, item) in map {
|
||||
// A non-scalar key has no Typst spelling; skip it rather than
|
||||
// emit something that will not parse.
|
||||
let key = match key {
|
||||
serde_yaml_ng::Value::String(s) => s.clone(),
|
||||
serde_yaml_ng::Value::Number(n) => n.to_string(),
|
||||
serde_yaml_ng::Value::Bool(b) => b.to_string(),
|
||||
_ => continue,
|
||||
};
|
||||
entries.push((key, from_yaml(item, strings_as_content)));
|
||||
}
|
||||
Value::Dict(entries)
|
||||
}
|
||||
// A tagged node, and anything a future YAML version adds.
|
||||
_ => Value::None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn empty_containers_use_the_right_syntax() {
|
||||
// `()` is the empty array and `(:)` is the empty dictionary. Swapping
|
||||
// them produces a type error deep inside the template.
|
||||
assert_eq!(Value::Array(Vec::new()).to_typst(0), "()");
|
||||
assert_eq!(Value::dict().to_typst(0), "(:)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_element_arrays_keep_the_trailing_comma() {
|
||||
let v = Value::Array(vec![Value::Int(1)]);
|
||||
let out = v.to_typst(0);
|
||||
assert!(out.contains("1,"), "got {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strings_are_escaped() {
|
||||
assert_eq!(Value::str("a\"b\\c").to_typst(0), "\"a\\\"b\\\\c\"");
|
||||
assert_eq!(Value::str("two\nlines").to_typst(0), "\"two\\nlines\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_is_not_escaped() {
|
||||
// Content blocks carry authored markup through verbatim; escaping them
|
||||
// would turn `*bold*` into literal asterisks.
|
||||
assert_eq!(Value::content("*bold*").to_typst(0), "[*bold*]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_are_quoted_only_when_they_have_to_be() {
|
||||
let mut d = Value::dict();
|
||||
d.insert("error-type", Value::Int(1));
|
||||
d.insert("2nd", Value::Int(2));
|
||||
let out = d.to_typst(0);
|
||||
assert!(out.contains("error-type: 1"), "got {out}");
|
||||
assert!(out.contains("\"2nd\": 2"), "got {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_fields_are_omitted_entirely() {
|
||||
let mut d = Value::dict();
|
||||
d.insert_some("correct", None);
|
||||
d.insert_some("number", Some(Value::Int(3)));
|
||||
assert!(!d.to_typst(0).contains("correct"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_finite_floats_do_not_produce_invalid_syntax() {
|
||||
assert_eq!(Value::Float(f64::NAN).to_typst(0), "none");
|
||||
assert_eq!(Value::Float(1.5).to_typst(0), "1.5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yaml_passes_through() {
|
||||
let yaml: serde_yaml_ng::Value =
|
||||
serde_yaml_ng::from_str("a: 1\nb: [x, y]\nc: true\n").unwrap();
|
||||
let out = from_yaml(&yaml, false).to_typst(0);
|
||||
assert!(out.contains("a: 1"), "got {out}");
|
||||
assert!(out.contains("\"x\""), "got {out}");
|
||||
assert!(out.contains("c: true"), "got {out}");
|
||||
}
|
||||
}
|
||||
+5
-14
@@ -26,7 +26,7 @@
|
||||
//! ## The loop
|
||||
//!
|
||||
//! ```text
|
||||
//! author items ──▶ validate ──▶ lint ──▶ assemble ──▶ export ──┐
|
||||
//! author items ──▶ validate ──▶ lint ──▶ assemble ──▶ export ───┐
|
||||
//! ▲ │
|
||||
//! │ administer
|
||||
//! │ │
|
||||
@@ -47,29 +47,20 @@
|
||||
//! already get right and then drifts from it. [`assessment::History`] derives usage
|
||||
//! by scanning the records.
|
||||
//!
|
||||
//! **Fingerprints cover only what a student saw.** Retag an item's metadata and its
|
||||
//! Fingerprints cover only what a student saw. Retag an item's metadata and its
|
||||
//! pooled statistics stay valid; reword the stem and they are marked stale. See
|
||||
//! [`item::Item::fingerprint`].
|
||||
//!
|
||||
//! **Validation reports everything at once.** Fixing one typo per run is not a
|
||||
//! Validation reports everything at once. Fixing one typo per run is not a
|
||||
//! workflow. [`error::Error::Invalid`] carries a list.
|
||||
//!
|
||||
//! **Validation and linting are separate.** [`bank::BankFile::validate`] enforces
|
||||
//! Validation and linting are separate. [`bank::BankFile::validate`] enforces
|
||||
//! what must be true; [`lint`] advises on what is usually a mistake, and every rule
|
||||
//! has a code you can silence.
|
||||
//!
|
||||
//! **Small samples are labelled as such.** Every statistic computed from a class of
|
||||
//! Small samples are labelled as such. Every statistic computed from a class of
|
||||
//! twenty-five is reported with the caveat it deserves rather than three decimal
|
||||
//! places of false precision.
|
||||
//!
|
||||
//! ## Dependency posture
|
||||
//!
|
||||
//! Deliberately small: serde, a YAML parser, clap, thiserror, and csv, plus arrow
|
||||
//! and parquet behind a default-on feature that can be switched off. Dates, PRNG,
|
||||
//! hashing, ZIP writing, and the psychometrics are implemented here rather than
|
||||
//! pulled in — see [`date`], [`rng`], [`hash`], [`zipfile`], [`irt`]. For a tool
|
||||
//! whose job is to still open a course repository in five years, that tradeoff
|
||||
//! favours fewer moving parts.
|
||||
|
||||
#![warn(missing_docs)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
+318
-15
@@ -84,6 +84,9 @@ enum Command {
|
||||
/// Produce a Canvas package, a printable exam, or Markdown.
|
||||
#[command(subcommand)]
|
||||
Export(ExportCommand),
|
||||
/// Inspect, dump, and configure the Typst export templates.
|
||||
#[command(subcommand)]
|
||||
Template(TemplateCommand),
|
||||
/// Read a grading export into the response store.
|
||||
#[command(subcommand)]
|
||||
Ingest(IngestCommand),
|
||||
@@ -328,6 +331,10 @@ enum ExportCommand {
|
||||
no_feedback: bool,
|
||||
},
|
||||
/// Render a printable exam, answer key, and answer sheet.
|
||||
///
|
||||
/// Each document is produced by injecting data into a Typst template rather
|
||||
/// than being built from scratch, so the layout is yours to change. Run
|
||||
/// `coursebank template dump` to get the defaults as editable files.
|
||||
Typst {
|
||||
/// Assessment id.
|
||||
id: String,
|
||||
@@ -337,6 +344,20 @@ enum ExportCommand {
|
||||
/// Output directory; defaults to build/.
|
||||
#[arg(long)]
|
||||
out: Option<PathBuf>,
|
||||
/// Which documents to write; defaults to all three.
|
||||
#[arg(long, value_name = "VARIANT")]
|
||||
variant: Vec<String>,
|
||||
/// Use this template file instead of the usual lookup. Only valid with a
|
||||
/// single --variant, since one file cannot be three documents.
|
||||
#[arg(long)]
|
||||
template: Option<PathBuf>,
|
||||
/// Also write the payload as JSON, for a template that reads it with
|
||||
/// `json("...")` rather than taking an injected region.
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
/// Print the payload and the resolved template path without writing.
|
||||
#[arg(long)]
|
||||
dry_run: bool,
|
||||
},
|
||||
/// Write the items as Markdown, for review.
|
||||
Md {
|
||||
@@ -351,6 +372,45 @@ enum ExportCommand {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
enum TemplateCommand {
|
||||
/// Show which template each document would use, and why.
|
||||
List {
|
||||
/// Resolve as if exporting this assessment, which brings the
|
||||
/// per-assessment template override into the lookup.
|
||||
#[arg(long)]
|
||||
assessment: Option<String>,
|
||||
},
|
||||
/// Write the built-in templates into templates/ so you can edit them.
|
||||
Dump {
|
||||
/// Which documents; defaults to all three.
|
||||
#[arg(long, value_name = "VARIANT")]
|
||||
variant: Vec<String>,
|
||||
/// Destination directory; defaults to templates/.
|
||||
#[arg(long)]
|
||||
out: Option<PathBuf>,
|
||||
/// Overwrite files that already exist.
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
/// Print to stdout instead of writing files.
|
||||
#[arg(long)]
|
||||
stdout: bool,
|
||||
},
|
||||
/// Write or show the render configuration.
|
||||
Config {
|
||||
/// Print the fully resolved configuration for this document, after every
|
||||
/// layer has been applied, instead of writing a starter file.
|
||||
#[arg(long, value_name = "VARIANT")]
|
||||
resolved: Option<String>,
|
||||
/// Destination path; defaults to templates/typst.yaml.
|
||||
#[arg(long)]
|
||||
out: Option<PathBuf>,
|
||||
/// Overwrite a config file that already exists.
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
struct IngestCommon {
|
||||
/// The assessment record these responses belong to.
|
||||
@@ -547,6 +607,7 @@ fn run(cli: &Cli) -> Result<Outcome> {
|
||||
Command::Assemble(args) => cmd_assemble(cli, args),
|
||||
Command::Usage(sub) => cmd_usage(cli, sub),
|
||||
Command::Export(sub) => cmd_export(cli, sub),
|
||||
Command::Template(sub) => cmd_template(cli, sub),
|
||||
Command::Ingest(sub) => cmd_ingest(cli, sub),
|
||||
Command::Analyze(sub) => cmd_analyze(cli, sub),
|
||||
Command::Calibrate(args) => cmd_calibrate(cli, args),
|
||||
@@ -1045,9 +1106,27 @@ fn cmd_export(cli: &Cli, sub: &ExportCommand) -> Result<Outcome> {
|
||||
println!("Import in Canvas: Settings -> Import Course Content -> QTI .zip file");
|
||||
Ok(Outcome::Ok)
|
||||
}
|
||||
ExportCommand::Typst { id, form, out } => {
|
||||
ExportCommand::Typst {
|
||||
id,
|
||||
form,
|
||||
out,
|
||||
variant,
|
||||
template,
|
||||
json,
|
||||
dry_run,
|
||||
} => {
|
||||
let record = load_record(&catalog, id)?;
|
||||
let dir = out.clone().unwrap_or(build);
|
||||
let variants = pick_variants(variant)?;
|
||||
|
||||
if template.is_some() && variants.len() > 1 {
|
||||
return Err(Error::usage(
|
||||
"--template applies to one document, but more than one --variant was \
|
||||
requested; pass --variant exam (or key, or answer-sheet) alongside it"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let forms: Vec<Form> = if form == "all" {
|
||||
if record.forms.is_empty() {
|
||||
vec![typst::Options::default().form]
|
||||
@@ -1058,23 +1137,79 @@ fn cmd_export(cli: &Cli, sub: &ExportCommand) -> Result<Outcome> {
|
||||
vec![pick_form(&record, form)?]
|
||||
};
|
||||
|
||||
for f in forms {
|
||||
let opts = typst::Options {
|
||||
form: f.clone(),
|
||||
..typst::Options::default()
|
||||
};
|
||||
let exam = typst::exam(&catalog, &record, &opts)?;
|
||||
let key = typst::answer_key(&catalog, &record, &opts)?;
|
||||
let sheet = typst::bubble_sheet(&catalog, &record, &opts)?;
|
||||
// Read once, outside both loops: the config describes the course, not
|
||||
// the form, and re-reading it per form would let a mid-run edit make
|
||||
// form A and form B disagree.
|
||||
let config_file = typst::load_config(&catalog.layout)?;
|
||||
let mut warned = false;
|
||||
let mut used_embedded = false;
|
||||
|
||||
for (suffix, contents) in [("", exam), ("-key", key), ("-answer-sheet", sheet)] {
|
||||
let path = dir.join(format!("{id}-{}{suffix}.typ", f.id));
|
||||
yaml::write_text(&path, &contents)?;
|
||||
println!("wrote {}", path.display());
|
||||
for f in &forms {
|
||||
for variant in &variants {
|
||||
let opts = typst::Options {
|
||||
form: f.clone(),
|
||||
variant: *variant,
|
||||
template: template.clone(),
|
||||
config: config_file.resolve(*variant),
|
||||
};
|
||||
|
||||
let rendered = typst::render(&catalog, &record, &opts)?;
|
||||
used_embedded |= rendered.origin == typst::Origin::Embedded;
|
||||
|
||||
for warning in &rendered.warnings {
|
||||
eprintln!("warning: {warning}");
|
||||
warned = true;
|
||||
}
|
||||
|
||||
let stem = format!("{id}-{}{}", f.id, variant.suffix());
|
||||
|
||||
if *dry_run {
|
||||
println!(
|
||||
"{}: {} question(s) from {} via {}",
|
||||
stem,
|
||||
rendered.payload.questions.len(),
|
||||
rendered.origin,
|
||||
rendered
|
||||
.slots
|
||||
.iter()
|
||||
.map(|s| s.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" + ")
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let path = dir.join(format!("{stem}.typ"));
|
||||
yaml::write_text(&path, &rendered.text)?;
|
||||
println!("wrote {} (from {})", path.display(), rendered.origin);
|
||||
|
||||
if *json {
|
||||
let json_path = dir.join(format!("{stem}.json"));
|
||||
yaml::write_text(&json_path, &rendered.payload.to_json()?)?;
|
||||
println!("wrote {}", json_path.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("\nCompile with: pixi run -e docs typst compile <file>.typ");
|
||||
Ok(Outcome::Ok)
|
||||
|
||||
if *dry_run {
|
||||
return Ok(Outcome::Ok);
|
||||
}
|
||||
|
||||
if !cli.quiet {
|
||||
println!("\nCompile with: pixi run -e docs typst compile <file>.typ");
|
||||
if used_embedded {
|
||||
println!(
|
||||
"Some of these used a built-in template. To take over the layout:\n \
|
||||
coursebank template dump"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(if warned {
|
||||
Outcome::Findings
|
||||
} else {
|
||||
Outcome::Ok
|
||||
})
|
||||
}
|
||||
ExportCommand::Md { id, with_key, out } => {
|
||||
let record = load_record(&catalog, id)?;
|
||||
@@ -1436,6 +1571,174 @@ fn cmd_data(cli: &Cli) -> Result<Outcome> {
|
||||
Ok(Outcome::Ok)
|
||||
}
|
||||
|
||||
/// Resolves the `--variant` flags, defaulting to every document.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `names` - the raw flag values, possibly empty.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The variants, deduplicated and in canonical order so that
|
||||
/// `--variant key --variant exam` still writes the paper first.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::Usage`] naming the valid tokens.
|
||||
fn pick_variants(names: &[String]) -> Result<Vec<typst::Variant>> {
|
||||
if names.is_empty() {
|
||||
return Ok(typst::Variant::ALL.to_vec());
|
||||
}
|
||||
let mut wanted = Vec::new();
|
||||
for name in names {
|
||||
let variant = typst::Variant::parse(name)?;
|
||||
if !wanted.contains(&variant) {
|
||||
wanted.push(variant);
|
||||
}
|
||||
}
|
||||
// Canonical order, not the order they were typed.
|
||||
Ok(typst::Variant::ALL
|
||||
.into_iter()
|
||||
.filter(|v| wanted.contains(v))
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn cmd_template(cli: &Cli, sub: &TemplateCommand) -> Result<Outcome> {
|
||||
let layout = Layout::new(&cli.course);
|
||||
|
||||
match sub {
|
||||
TemplateCommand::List { assessment } => {
|
||||
let id = assessment.as_deref();
|
||||
println!("Templates are looked up in this order, first match wins:\n");
|
||||
|
||||
for variant in typst::Variant::ALL {
|
||||
println!("{}", variant.as_str());
|
||||
let mut resolved = false;
|
||||
for path in typst::template::candidates(&layout, variant, id) {
|
||||
let present = path.is_file();
|
||||
let mark = if present && !resolved {
|
||||
resolved = true;
|
||||
"->"
|
||||
} else {
|
||||
" "
|
||||
};
|
||||
let state = if present { "" } else { " (absent)" };
|
||||
println!(" {mark} {}{state}", path.display());
|
||||
}
|
||||
let mark = if resolved { " " } else { "->" };
|
||||
println!(" {mark} built-in");
|
||||
|
||||
// Reporting the slots requires parsing, and a template with broken
|
||||
// markers should be named here rather than at export time.
|
||||
match typst::template::load(&layout, variant, id, None) {
|
||||
Ok(template) => {
|
||||
let slots: Vec<&str> =
|
||||
template.slots().iter().map(|s| s.as_str()).collect();
|
||||
if slots.is_empty() {
|
||||
println!(" slots: none — this template injects nothing");
|
||||
} else {
|
||||
println!(" slots: {}", slots.join(", "));
|
||||
}
|
||||
}
|
||||
Err(e) => println!(" unusable: {e}"),
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
let config = typst::config_path(&layout);
|
||||
if config.is_file() {
|
||||
println!("Config: {}", config.display());
|
||||
} else {
|
||||
println!(
|
||||
"Config: none ({} is absent, so built-in defaults apply)",
|
||||
config.display()
|
||||
);
|
||||
}
|
||||
Ok(Outcome::Ok)
|
||||
}
|
||||
|
||||
TemplateCommand::Dump {
|
||||
variant,
|
||||
out,
|
||||
force,
|
||||
stdout,
|
||||
} => {
|
||||
let variants = pick_variants(variant)?;
|
||||
|
||||
if *stdout {
|
||||
for (index, v) in variants.iter().enumerate() {
|
||||
if index > 0 {
|
||||
println!();
|
||||
}
|
||||
if variants.len() > 1 {
|
||||
println!("// ── {} ──", v.template_file());
|
||||
}
|
||||
print!("{}", typst::template::embedded(*v));
|
||||
}
|
||||
return Ok(Outcome::Ok);
|
||||
}
|
||||
|
||||
let dir = out.clone().unwrap_or_else(|| layout.templates());
|
||||
let (written, skipped) = typst::template::dump(&dir, &variants, *force)?;
|
||||
|
||||
for path in &written {
|
||||
println!("wrote {}", path.display());
|
||||
}
|
||||
for path in &skipped {
|
||||
println!(
|
||||
"kept {} (already exists; --force to overwrite)",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
|
||||
if !written.is_empty() && !cli.quiet {
|
||||
println!(
|
||||
"\nThese are yours to edit. Only the marked regions are replaced on \
|
||||
export,\nso restyle freely:\n typst watch {}",
|
||||
dir.join(typst::Variant::Exam.template_file()).display()
|
||||
);
|
||||
}
|
||||
// Skipped files are worth an exit code: a script that expected to
|
||||
// refresh them did not.
|
||||
Ok(if skipped.is_empty() {
|
||||
Outcome::Ok
|
||||
} else {
|
||||
Outcome::Findings
|
||||
})
|
||||
}
|
||||
|
||||
TemplateCommand::Config {
|
||||
resolved,
|
||||
out,
|
||||
force,
|
||||
} => {
|
||||
if let Some(name) = resolved {
|
||||
let variant = typst::Variant::parse(name)?;
|
||||
let config_file = typst::load_config(&layout)?;
|
||||
let config = config_file.resolve(variant);
|
||||
println!(
|
||||
"# Resolved configuration for `{}`, after every layer.",
|
||||
variant.as_str()
|
||||
);
|
||||
print!("{}", config.to_yaml()?);
|
||||
return Ok(Outcome::Ok);
|
||||
}
|
||||
|
||||
let path = out.clone().unwrap_or_else(|| typst::config_path(&layout));
|
||||
if path.exists() && !force {
|
||||
return Err(Error::usage(format!(
|
||||
"{} already exists; pass --force to overwrite it, or --resolved <variant> to \
|
||||
see what it currently produces",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
yaml::write_text(&path, typst::CONFIG_TEMPLATE)?;
|
||||
println!("wrote {}", path.display());
|
||||
Ok(Outcome::Ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads the catalog, with a friendlier message when the directory is not a course.
|
||||
fn load(cli: &Cli) -> Result<Catalog> {
|
||||
let course_file = Layout::new(&cli.course).course_file();
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//!
|
||||
//! Grading exports do not know about your item bank. Gradescope gives you
|
||||
//! `14.csv`; Canvas gives you a column header. Both identify questions by
|
||||
//! *position on a form*. An item bank identifies questions by stable id. The
|
||||
//! position on a form. An item bank identifies questions by stable id. The
|
||||
//! assessment record is the only place those two namespaces meet, and without it
|
||||
//! there is no way to say that question 14 of Exam 4 was
|
||||
//! `docking::q-scoring-003` at version 2.
|
||||
@@ -15,7 +15,7 @@
|
||||
//! assessment records: an item was used exactly when it appears on a record. The
|
||||
//! records are the source of truth, and they are small, readable, and diffable.
|
||||
//!
|
||||
//! Each placement stores the resolved key and content fingerprint *as used*. A
|
||||
//! Each placement stores the resolved key and content fingerprint as used. A
|
||||
//! year later, when the item has been reworded twice, you can still see what the
|
||||
//! students in front of you were asked.
|
||||
|
||||
@@ -66,7 +66,7 @@ pub struct AssessmentFile {
|
||||
pub struct Assessment {
|
||||
/// Stable id, e.g. `exam-4-2026s`. Response tables carry this.
|
||||
pub id: String,
|
||||
/// Human title as printed, e.g. `Exam 4`.
|
||||
/// Human title as printed, e.g., `Exam 4`.
|
||||
pub title: String,
|
||||
/// The term this administration belongs to. Items outlive terms, so the term
|
||||
/// lives here rather than on the item.
|
||||
|
||||
+12
-12
@@ -3,13 +3,13 @@
|
||||
//! One bank per topic (or per lecture, if that suits how you teach) is the unit
|
||||
//! of authoring. Banks are small enough to review in a pull request, they let
|
||||
//! two people write questions without colliding, and `bank.scope` records what
|
||||
//! the file is *for* so `coursebank catalog` can tell you that you have eleven
|
||||
//! the file is for so `coursebank catalog` can tell you that you have eleven
|
||||
//! items on enzyme kinetics and none on regulation.
|
||||
//!
|
||||
//! Validation here is split in two on purpose. [`BankFile::validate`] checks what
|
||||
//! must be true for the file to be usable at all: ids are unique, a keyed answer
|
||||
//! exists, an approved item is fully specified, a level and its cognitive process
|
||||
//! agree. The softer question of whether an item is *well written* lives in
|
||||
//! agree. The softer question of whether an item is well written lives in
|
||||
//! [`crate::lint`], because those checks are advisory and you should be able to
|
||||
//! ship a file that trips a few of them.
|
||||
|
||||
@@ -76,7 +76,7 @@ pub struct BankMeta {
|
||||
/// What a bank is scoped to.
|
||||
///
|
||||
/// A bank may be scoped by lecture, by objective, by topic, or by none of them.
|
||||
/// Declaring the scope is what lets the catalog report *gaps*: it can only tell
|
||||
/// Declaring the scope is what lets the catalog report gaps: it can only tell
|
||||
/// you that lecture 12 has no Apply-level items if it knows lecture 12 is
|
||||
/// supposed to be covered here.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
@@ -348,7 +348,7 @@ fn validate_item(
|
||||
issues.push("version must be at least 1".into());
|
||||
}
|
||||
|
||||
// --- options -----------------------------------------------------------
|
||||
// --- options ---
|
||||
if it.options.len() < 2 {
|
||||
issues.push(format!(
|
||||
"needs at least 2 options, has {}",
|
||||
@@ -414,7 +414,7 @@ fn validate_item(
|
||||
}
|
||||
}
|
||||
|
||||
// --- key ---------------------------------------------------------------
|
||||
// --- key ---
|
||||
let keys = it.key_indices();
|
||||
match it.format {
|
||||
Format::SingleBestAnswer => {
|
||||
@@ -446,7 +446,7 @@ fn validate_item(
|
||||
}
|
||||
}
|
||||
|
||||
// --- level and process must agree -------------------------------------
|
||||
// --- level and process must agree ---
|
||||
if let Some(p) = it.cognitive_process {
|
||||
if !it.level.allows(p) {
|
||||
issues.push(format!(
|
||||
@@ -457,7 +457,7 @@ fn validate_item(
|
||||
}
|
||||
}
|
||||
|
||||
// --- design plausibility ----------------------------------------------
|
||||
// --- design plausibility ---
|
||||
if let Some(d) = &it.design {
|
||||
if let Some(x) = d.expected_difficulty {
|
||||
if !(0.0..=1.0).contains(&x) {
|
||||
@@ -475,7 +475,7 @@ fn validate_item(
|
||||
}
|
||||
}
|
||||
|
||||
// --- calibration plausibility -----------------------------------------
|
||||
// --- calibration plausibility ---
|
||||
if let Some(c) = &it.calibration {
|
||||
if let Some(p) = c.p_value {
|
||||
if !(0.0..=1.0).contains(&p) {
|
||||
@@ -510,7 +510,7 @@ fn validate_item(
|
||||
}
|
||||
}
|
||||
|
||||
// --- history must be coherent -----------------------------------------
|
||||
// --- history must be coherent ---
|
||||
let mut last_version = 0u32;
|
||||
for (i, h) in it.history.iter().enumerate() {
|
||||
if h.version <= last_version {
|
||||
@@ -529,7 +529,7 @@ fn validate_item(
|
||||
));
|
||||
}
|
||||
|
||||
// --- retirement -------------------------------------------------------
|
||||
// --- retirement ---
|
||||
if it.retired.is_some() && it.status != Status::Retired {
|
||||
issues.push(format!(
|
||||
"has a `retired` block but status is `{}`",
|
||||
@@ -537,7 +537,7 @@ fn validate_item(
|
||||
));
|
||||
}
|
||||
|
||||
// --- approval gate ----------------------------------------------------
|
||||
// --- approval gate ---
|
||||
// Approval is what permits an item onto a graded assessment, so it is the
|
||||
// right place to require that the item is fully sourced and designed.
|
||||
if it.status == Status::Approved {
|
||||
@@ -555,7 +555,7 @@ fn validate_item(
|
||||
}
|
||||
}
|
||||
|
||||
// --- cross-file references --------------------------------------------
|
||||
// --- cross-file references ---
|
||||
if let Some(c) = course {
|
||||
for lo in &it.learning_objectives {
|
||||
match c.learning_objectives.get(lo) {
|
||||
|
||||
@@ -609,6 +609,17 @@ impl Layout {
|
||||
self.root.join("schema")
|
||||
}
|
||||
|
||||
/// Directory holding Typst export templates and their configuration.
|
||||
///
|
||||
/// Unlike the other directories, this one is *not* created by
|
||||
/// [`Layout::create_all`]. Its absence is meaningful: a course with no
|
||||
/// `templates/` directory uses the templates compiled into the binary, and
|
||||
/// creating an empty one on `init` would suggest a customization step is
|
||||
/// required when it is not. `coursebank template dump` creates it on demand.
|
||||
pub fn templates(&self) -> PathBuf {
|
||||
self.root.join("templates")
|
||||
}
|
||||
|
||||
/// Creates every directory in the layout.
|
||||
///
|
||||
/// # Errors
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@
|
||||
//! Keeping them adjacent is what turns a question bank into an instrument you
|
||||
//! can improve, because every administration produces a checkable prediction.
|
||||
//!
|
||||
//! One deliberate departure from a naive design: [`Calibration`] is *cumulative*
|
||||
//! One deliberate departure from a naive design: [`Calibration`] is cumulative
|
||||
//! rather than per-administration. Raw per-response data belongs in the Parquet
|
||||
//! tables under `data/`, which are far better at holding it, and an item's YAML
|
||||
//! holds the rolled-up estimate plus a list of which administrations went into
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
//! The pedagogical vocabulary: levels, cognitive processes, error types, and
|
||||
//! workflow states.
|
||||
//!
|
||||
//! These are enums rather than strings on purpose. A typo in
|
||||
//! `cognitive_process` should fail to parse, not silently create a new category
|
||||
//! that then splits your coverage report in two. Just as importantly, the
|
||||
//! relation between a level and the processes that belong to it is encoded here
|
||||
//! in one place, so "level 3, cognitive_process: recall" is a validation error
|
||||
//! rather than a label that quietly contradicts itself.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user