From 07e3131f174ad2f16768ea542f2e57a7249ee44f Mon Sep 17 00:00:00 2001 From: Alex Maldonado Date: Thu, 6 Aug 2026 16:45:50 -0400 Subject: [PATCH] feat: improve typst handling --- pixi.toml | 6 +- src/analysis.rs | 2 +- src/analysis/classical.rs | 26 +- src/authoring/lint.rs | 2 +- src/export/typst.rs | 636 +++++----- src/export/typst/config.rs | 746 +++++++++++ src/export/typst/payload.rs | 1223 +++++++++++++++++++ src/export/typst/template.rs | 743 +++++++++++ src/export/typst/templates/answer-sheet.typ | 71 ++ src/export/typst/templates/exam.typ | 204 ++++ src/export/typst/templates/key.typ | 154 +++ src/export/typst/value.rs | 349 ++++++ src/lib.rs | 19 +- src/main.rs | 333 ++++- src/model/assessment.rs | 6 +- src/model/bank.rs | 24 +- src/model/course.rs | 11 + src/model/item.rs | 2 +- src/model/taxonomy.rs | 7 - 19 files changed, 4148 insertions(+), 416 deletions(-) create mode 100644 src/export/typst/config.rs create mode 100644 src/export/typst/payload.rs create mode 100644 src/export/typst/template.rs create mode 100644 src/export/typst/templates/answer-sheet.typ create mode 100644 src/export/typst/templates/exam.typ create mode 100644 src/export/typst/templates/key.typ create mode 100644 src/export/typst/value.rs diff --git a/pixi.toml b/pixi.toml index c311ef3..7577d54 100644 --- a/pixi.toml +++ b/pixi.toml @@ -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] diff --git a/src/analysis.rs b/src/analysis.rs index 12e148c..22739a1 100644 --- a/src/analysis.rs +++ b/src/analysis.rs @@ -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 diff --git a/src/analysis/classical.rs b/src/analysis/classical.rs index ce74c3a..29ac8cf 100644 --- a/src/analysis/classical.rs +++ b/src/analysis/classical.rs @@ -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 diff --git a/src/authoring/lint.rs b/src/authoring/lint.rs index ed71eae..a2dd17c 100644 --- a/src/authoring/lint.rs +++ b/src/authoring/lint.rs @@ -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 } "#, ); diff --git a/src/export/typst.rs b/src/export/typst.rs index 96a9910..6503b0e 100644 --- a/src/export/typst.rs +++ b/src/export/typst.rs @@ -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, + /// 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, + /// 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, +} + +/// 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 { + 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::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 { + 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 { - 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 { - 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 = record - .items - .iter() - .filter(|p| !p.credit_overrides.is_empty()) - .map(|p| { - let list: Vec = 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 = 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 { - 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 = (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 { + 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); diff --git a/src/export/typst/config.rs b/src/export/typst/config.rs new file mode 100644 index 0000000..c15da39 --- /dev/null +++ b/src/export/typst/config.rs @@ -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 { + 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::>() + .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, + + /// 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, +} + +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 { + 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, + /// Overrides applied to every variant. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub defaults: Option, + /// Overrides applied to one variant, on top of `defaults`. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub variants: BTreeMap, +} + +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, + /// See [`RenderConfig::question_fn`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub question_fn: Option, + /// See [`RenderConfig::meta_binding`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub meta_binding: Option, + /// See [`RenderConfig::data_binding`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub data_binding: Option, + /// See [`RenderConfig::reveal`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reveal: Option, + /// See [`RenderConfig::letters`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub letters: Option, + /// See [`RenderConfig::content`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub content: Option, + /// See [`RenderConfig::stimulus`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stimulus: Option, + /// See [`RenderConfig::fields`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fields: Option, + /// See [`RenderConfig::number_from_record`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub number_from_record: Option, + /// 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, +} + +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); + } +} diff --git a/src/export/typst/payload.rs b/src/export/typst/payload.rs new file mode 100644 index 0000000..0e0f024 --- /dev/null +++ b/src/export/typst/payload.rs @@ -0,0 +1,1223 @@ +//! The data a template is given. +//! +//! One structure, built once, emitted three ways: as a Typst dictionary for the +//! `meta` and `data` slots, as a sequence of function calls for the `questions` +//! slot, and as JSON for a template that would rather call `json("...")` — which +//! is the shape a hand-written Typst exam usually already has. +//! +//! Because it is built once, the paper, the key, and the answer sheet cannot +//! disagree about what form B looked like. That was already the rule and it still +//! is: option order comes from [`select::option_order`] against the form's +//! recorded seed, never from anything stored, so every export of a form agrees +//! with every other export of that form. A key that disagrees with its paper is +//! discovered by the whole room at once. +//! +//! What does *not* appear here is anything about appearance. There is no `paper` +//! size, no font, no margin. Those were arguments to the old renderer and are now +//! either a `#set` rule in the template or a value under +//! [`RenderConfig::extra`](super::config::RenderConfig::extra). + +use std::collections::{BTreeMap, BTreeSet}; + +use serde::Serialize; + +use crate::assessment::{AssessmentFile, Form, Placement, Platform}; +use crate::catalog::Catalog; +use crate::error::Result; +use crate::item::{Choice, Item}; +use crate::markup; +use crate::select; +use crate::taxonomy::{Format, Level}; + +use super::config::{RenderConfig, StimulusMode}; +use super::value::{self, Value}; + +/// Everything a template is told about one rendering. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct Payload { + /// Which tool produced this, and when. + pub generator: Generator, + /// Course identity. + pub course: Course, + /// The administration. + pub assessment: Assessment, + /// The form being rendered. + pub form: FormInfo, + /// Counts and sums, so a template does not have to derive them. + pub totals: Totals, + /// Learning objectives referenced by the printed items, by id. + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + pub objectives: BTreeMap, + /// Shared stimuli, by id. Populated when the render config says stimuli are + /// shared rather than repeated with each item. + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + pub stimuli: BTreeMap, + /// The printed questions, in printed order. + pub questions: Vec, + /// Recorded numbers that were dropped after administration and are therefore + /// not printed. Worth naming on a key so a grader is not left wondering. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub dropped: Vec, + /// Whatever the render config's `extra` block held. + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + pub extra: BTreeMap, +} + +/// Provenance, so a printed paper found in a drawer can be traced. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct Generator { + /// The tool name. + pub tool: String, + /// The tool version. + pub version: String, +} + +/// Course identity. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct Course { + /// Catalog code. + pub code: String, + /// Full title. + pub title: String, + /// The term the course instance runs in. + pub term: String, + /// Granting institution. + #[serde(skip_serializing_if = "Option::is_none")] + pub institution: Option, + /// Instructors of record. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub instructors: Vec, + /// The filesystem-safe short name. + pub slug: String, +} + +/// The administration being rendered. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct Assessment { + /// Stable id. + pub id: String, + /// Human title as printed. + pub title: String, + /// The term this administration belongs to. + pub term: String, + /// What kind of assessment. + pub kind: String, + /// Where it was administered. + pub platform: String, + /// The date as `YYYY-MM-DD`, when recorded. + #[serde(skip_serializing_if = "Option::is_none")] + pub date: Option, + /// The date's parts, so a template can format it however it likes rather than + /// parsing the string back apart. + #[serde(skip_serializing_if = "Option::is_none")] + pub date_parts: Option, + /// Time allowed, in minutes. + #[serde(skip_serializing_if = "Option::is_none")] + pub minutes_allowed: Option, + /// Instructions printed at the top of the paper. + #[serde(skip_serializing_if = "Option::is_none")] + pub instructions: Option, + /// Instructor notes. Emitted only when the render config reveals rationale. + #[serde(skip_serializing_if = "Option::is_none")] + pub notes: Option, +} + +/// A date split into its parts. +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct DateParts { + /// Year. + pub year: i32, + /// Month, 1 through 12. + pub month: u32, + /// Day of month. + pub day: u32, +} + +/// The form being rendered. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct FormInfo { + /// The form id, e.g. `A`. + pub id: String, + /// The seed that produced this form's permutation. + pub seed: u64, + /// Whether item order was shuffled. + pub shuffle_items: bool, + /// Whether option order was shuffled. + pub shuffle_options: bool, + /// How many forms the record declares. A template usually prints the form + /// label only when there is more than one. + pub count: usize, +} + +/// Counts and sums a template would otherwise compute. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct Totals { + /// Printed questions, bonus included. + pub questions: usize, + /// Printed scored questions. + pub scored: usize, + /// Printed bonus questions. + pub bonus: usize, + /// Summed points of the scored questions. This is the number that goes on the + /// cover, and it excludes bonus by definition. + pub points: f64, + /// Summed points of the bonus questions. + pub bonus_points: f64, + /// How many printed scored questions sit at each level, keyed by level code. + pub level_counts: BTreeMap, +} + +/// A learning objective, denormalized for printing. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct Objective { + /// The objective text. + pub text: String, + /// The unit it belongs to. + #[serde(skip_serializing_if = "Option::is_none")] + pub unit: Option, +} + +/// A shared stimulus. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct Stimulus { + /// The vignette body. + pub body: String, + /// A caption for the asset. + #[serde(skip_serializing_if = "Option::is_none")] + pub caption: Option, + /// A path to an image or data file, relative to the course root. + #[serde(skip_serializing_if = "Option::is_none")] + pub asset: Option, +} + +/// A figure or data file printed with an item. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct Asset { + /// Path relative to the course root. + pub path: String, + /// Alt text. + #[serde(skip_serializing_if = "Option::is_none")] + pub alt: Option, + /// A caption. + #[serde(skip_serializing_if = "Option::is_none")] + pub caption: Option, +} + +/// One printed question. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct Question { + /// The number as printed. By default this is the number recorded in the + /// assessment record, not the position on the page, because it is the join key + /// to every grading export and response row. + pub number: u32, + /// The one-based position on the page, which differs from `number` whenever an + /// item has been dropped. + pub position: usize, + /// The item's global id, `bank::item`. Omitted unless the field is enabled. + #[serde(skip_serializing_if = "Option::is_none")] + pub uid: Option, + /// The item's short title. + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + /// Point value. + #[serde(skip_serializing_if = "Option::is_none")] + pub points: Option, + /// Whether this is scored outside the graded total. + pub bonus: bool, + /// Cognitive level code, 1 through 5. + #[serde(skip_serializing_if = "Option::is_none")] + pub level: Option, + /// The level's name. + #[serde(skip_serializing_if = "Option::is_none")] + pub level_name: Option, + /// The item's response format. + pub format: String, + /// Whether more than one option is keyed correct, which a paper has to say out + /// loud or students will assume otherwise. + pub multi_select: bool, + /// The prompt, as Typst markup. + pub stem: String, + /// The id of a shared stimulus, when the config keeps stimuli shared. + #[serde(skip_serializing_if = "Option::is_none")] + pub stimulus_id: Option, + /// The stimulus itself, when the config repeats it with each item. + #[serde(skip_serializing_if = "Option::is_none")] + pub stimulus: Option, + /// The options in printed order. + pub options: Vec, + /// The keyed letters *as printed on this form*. Omitted unless the config + /// reveals the key. + #[serde(skip_serializing_if = "Option::is_none")] + pub key: Option>, + /// Credit awarded to non-keyed letters after administration, by printed + /// letter. Omitted unless the config reveals the key. + #[serde(skip_serializing_if = "Option::is_none")] + pub credit_overrides: Option>, + /// Learning objective ids. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub learning_objectives: Vec, + /// Topic tags. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub topics: Vec, + /// Figures and data files. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub assets: Vec, + /// Authored predictions, when the field is enabled. + #[serde(skip_serializing_if = "Option::is_none")] + pub design: Option>, + /// Pooled statistics, when the field is enabled. + #[serde(skip_serializing_if = "Option::is_none")] + pub calibration: Option>, +} + +/// One printed option. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct Opt { + /// The label as printed, generated from the printed position. + pub letter: String, + /// The one-based printed position. + pub position: usize, + /// The letter this option carries in the bank, before shuffling. Omitted + /// unless the field is enabled; it belongs on a review copy, not a paper. + #[serde(skip_serializing_if = "Option::is_none")] + pub source_letter: Option, + /// The option text, as Typst markup. + pub text: String, + /// Whether this option is keyed correct. **Absent entirely** unless the config + /// reveals the key, rather than present and false. + #[serde(skip_serializing_if = "Option::is_none")] + pub correct: Option, + /// Credit this option earns, from 0 to 1. Absent unless the key is revealed. + #[serde(skip_serializing_if = "Option::is_none")] + pub credit: Option, + /// Whether a wrong option was judged defensible. + #[serde(skip_serializing_if = "Option::is_none")] + pub defensible: Option, + /// Instructor rationale. Absent unless the config reveals rationale. + #[serde(skip_serializing_if = "Option::is_none")] + pub explanation: Option, + /// The misconception this distractor targets. + #[serde(skip_serializing_if = "Option::is_none")] + pub misconception: Option, + /// The category of that error. + #[serde(skip_serializing_if = "Option::is_none")] + pub error_type: Option, + /// Text written to be released to students afterwards. + #[serde(skip_serializing_if = "Option::is_none")] + pub feedback: Option, +} + +/// Builds the payload for one form. +/// +/// # Arguments +/// +/// * `catalog` - the loaded course. +/// * `record` - the assessment record. +/// * `form` - the form to render. +/// * `config` - what to include, and how. +/// +/// # Returns +/// +/// The payload. Dropped placements are excluded from `questions` and listed in +/// `dropped`. +/// +/// # Errors +/// +/// Returns [`Error::Unresolved`](crate::error::Error::Unresolved) when a placement +/// references an item that is not in the catalog. +pub fn build( + catalog: &Catalog, + record: &AssessmentFile, + form: &Form, + config: &RenderConfig, +) -> Result { + let course = &catalog.course; + let default_points = course.policy.points_per_item; + + let mut questions = Vec::new(); + let mut objective_ids: BTreeSet = BTreeSet::new(); + let mut stimulus_ids: BTreeSet = BTreeSet::new(); + let mut totals = Totals { + questions: 0, + scored: 0, + bonus: 0, + points: 0.0, + bonus_points: 0.0, + level_counts: BTreeMap::new(), + }; + + for placement in select::layout(record, form) { + if placement.dropped { + continue; + } + let entry = catalog.require(&placement.item)?; + let item = &entry.item; + let position = questions.len() + 1; + + let points = placement + .points + .unwrap_or_else(|| item.points(default_points)); + totals.questions += 1; + if placement.bonus { + totals.bonus += 1; + totals.bonus_points += points; + } else { + totals.scored += 1; + totals.points += points; + *totals + .level_counts + .entry(placement_level(&placement, item).code()) + .or_insert(0) += 1; + } + + let objectives = if placement.learning_objectives.is_empty() { + item.learning_objectives.clone() + } else { + placement.learning_objectives.clone() + }; + if config.fields.objectives { + objective_ids.extend(objectives.iter().cloned()); + } + + let (stimulus_id, stimulus) = match (&item.stimulus, config.stimulus) { + (Some(id), StimulusMode::Inline) => ( + None, + course.stimuli.get(id).map(|s| Stimulus { + body: markup::to_typst(&s.body), + caption: s.caption.as_deref().map(markup::to_typst), + asset: s.asset.clone(), + }), + ), + (Some(id), StimulusMode::Shared) => { + stimulus_ids.insert(id.clone()); + (Some(id.clone()), None) + } + _ => (None, None), + }; + + let order = select::option_order(form, &placement.item, item.options.len()); + let options: Vec = order + .iter() + .enumerate() + .map(|(position, source_index)| option(&item.options[*source_index], position, config)) + .collect(); + + let key = if config.reveal.shows_key() { + Some( + options + .iter() + .filter(|o| o.correct == Some(true)) + .map(|o| o.letter.clone()) + .collect(), + ) + } else { + None + }; + + // Overrides are recorded against the letters the students saw, which are + // this form's printed letters, so they need no remapping. + let has_overrides = !placement.credit_overrides.is_empty(); + let credit_overrides = if config.reveal.shows_key() && has_overrides { + Some(placement.credit_overrides.clone()) + } else { + None + }; + + let level = placement_level(&placement, item); + + questions.push(Question { + number: if config.number_from_record { + placement.number + } else { + position as u32 + }, + position, + uid: config.fields.uid.then(|| entry.uid.clone()), + title: config + .fields + .title + .then(|| item.title.clone()) + .flatten() + .map(|t| markup::to_typst(&t)), + points: config.fields.points.then_some(points), + bonus: placement.bonus, + level: config.fields.level.then(|| level.code()), + level_name: config.fields.level.then(|| level.name().to_string()), + format: format_token(item.format).to_string(), + multi_select: item.is_multi_key(), + stem: markup::to_typst(&item.stem), + stimulus_id, + stimulus, + options, + key, + credit_overrides, + learning_objectives: if config.fields.objectives { + objectives + } else { + Vec::new() + }, + topics: if config.fields.topics { + item.topics.clone() + } else { + Vec::new() + }, + assets: if config.fields.assets { + item.assets + .iter() + .map(|a| Asset { + path: a.path.clone(), + alt: a.alt.clone(), + caption: a.caption.as_deref().map(markup::to_typst), + }) + .collect() + } else { + Vec::new() + }, + design: config.fields.design.then(|| design(item)).flatten(), + calibration: config + .fields + .calibration + .then(|| calibration(item)) + .flatten(), + }); + } + + let objectives = objective_ids + .into_iter() + .filter_map(|id| { + course.learning_objectives.get(&id).map(|o| { + ( + id, + Objective { + text: markup::to_typst(&o.text), + unit: o.unit.clone(), + }, + ) + }) + }) + .collect(); + + let stimuli = stimulus_ids + .into_iter() + .filter_map(|id| { + course.stimuli.get(&id).map(|s| { + ( + id, + Stimulus { + body: markup::to_typst(&s.body), + caption: s.caption.as_deref().map(markup::to_typst), + asset: s.asset.clone(), + }, + ) + }) + }) + .collect(); + + Ok(Payload { + generator: Generator { + tool: "coursebank".to_string(), + version: crate::VERSION.to_string(), + }, + course: Course { + code: course.course.code.clone(), + title: course.course.title.clone(), + term: course.course.term.clone(), + institution: course.course.institution.clone(), + instructors: course.course.instructors.clone(), + slug: course.course.slug(), + }, + assessment: Assessment { + id: record.assessment.id.clone(), + title: record.assessment.title.clone(), + term: record + .assessment + .term + .clone() + .unwrap_or_else(|| course.course.term.clone()), + kind: record.assessment.kind.as_str().to_string(), + platform: platform_token(record.assessment.platform).to_string(), + date: record.assessment.date.map(|d| d.to_string()), + date_parts: record.assessment.date.map(|d| DateParts { + year: d.year, + month: d.month, + day: d.day, + }), + minutes_allowed: record.assessment.minutes_allowed, + instructions: record + .assessment + .instructions + .as_deref() + .map(markup::to_typst), + notes: if config.reveal.shows_rationale() { + record.assessment.notes.as_deref().map(markup::to_typst) + } else { + None + }, + }, + form: FormInfo { + id: form.id.clone(), + seed: form.seed, + shuffle_items: form.shuffle_items, + shuffle_options: form.shuffle_options, + count: record.forms.len().max(1), + }, + totals, + objectives, + stimuli, + questions, + dropped: dropped_numbers(record), + extra: config.extra.clone(), + }) +} + +/// Builds one printed option. +fn option(choice: &Choice, position: usize, config: &RenderConfig) -> Opt { + let reveal = config.reveal; + Opt { + letter: config.letters.label(position), + position: position + 1, + source_letter: config.fields.source_letters.then(|| choice.id.clone()), + text: markup::to_typst(&choice.text), + // These four are the ones that must be genuinely absent rather than + // present-and-falsy on a student paper. + correct: reveal.shows_key().then_some(choice.correct), + credit: reveal.shows_key().then(|| choice.credit()), + defensible: reveal.shows_key().then_some(choice.defensible), + explanation: reveal + .shows_rationale() + .then(|| choice.explanation.as_deref().map(markup::to_typst)) + .flatten(), + misconception: reveal + .shows_rationale() + .then(|| choice.misconception.as_deref().map(markup::to_typst)) + .flatten(), + error_type: reveal + .shows_rationale() + .then(|| choice.error_type.map(|e| e.as_str().to_string())) + .flatten(), + feedback: reveal + .shows_rationale() + .then(|| choice.feedback_student.as_deref().map(markup::to_typst)) + .flatten(), + } +} + +/// The recorded numbers that were dropped after administration. +fn dropped_numbers(record: &AssessmentFile) -> Vec { + record + .items + .iter() + .filter(|p| p.dropped) + .map(|p| p.number) + .collect() +} + +/// The numeric parts of an item's `design` block, if it has any. +fn design(item: &Item) -> Option> { + let design = item.design.as_ref()?; + let mut map = BTreeMap::new(); + if let Some(v) = design.expected_difficulty { + map.insert("expected-difficulty".to_string(), v); + } + if let Some(v) = design.expected_time_seconds { + map.insert("expected-time-seconds".to_string(), v); + } + (!map.is_empty()).then_some(map) +} + +/// The numeric parts of an item's pooled statistics, if it has any. +fn calibration(item: &Item) -> Option> { + let calibration = item.calibration.as_ref()?; + let mut map = BTreeMap::new(); + if let Some(v) = calibration.p_value { + map.insert("p-value".to_string(), v); + } + if let Some(v) = calibration.point_biserial { + map.insert("point-biserial".to_string(), v); + } + if let Some(v) = calibration.discrimination_index { + map.insert("discrimination-index".to_string(), v); + } + if let Some(n) = calibration.n_examinees { + map.insert("n-examinees".to_string(), n as f64); + } + (!map.is_empty()).then_some(map) +} + +/// The token for a response format. +fn format_token(format: Format) -> &'static str { + match format { + Format::SingleBestAnswer => "single_best_answer", + Format::MultipleResponse => "multiple_response", + Format::TrueFalse => "true_false", + } +} + +/// The token for an administration platform. +fn platform_token(platform: Platform) -> &'static str { + match platform { + Platform::Paper => "paper", + Platform::Canvas => "canvas", + Platform::Other => "other", + } +} + +/// Whether a placement contributes a level to the counts. +/// +/// Kept as a named helper so the fallback from placement to item is stated once. +fn placement_level(placement: &Placement, item: &Item) -> Level { + placement.level.unwrap_or(item.level) +} + +impl Payload { + /// The metadata half of the payload, as a Typst dictionary. + /// + /// # Arguments + /// + /// * `config` - governs whether markup becomes content blocks or strings. + /// + /// # Returns + /// + /// A Typst value with everything except `questions`. + pub fn meta_value(&self, config: &RenderConfig) -> Value { + let content = config.content.is_content(); + let mut root = Value::dict(); + + let mut generator = Value::dict(); + generator.insert("tool", Value::str(self.generator.tool.as_str())); + generator.insert("version", Value::str(self.generator.version.as_str())); + root.insert("generator", generator); + + let mut course = Value::dict(); + course.insert("code", Value::str(self.course.code.as_str())); + course.insert("title", Value::str(self.course.title.as_str())); + course.insert("term", Value::str(self.course.term.as_str())); + course.insert("slug", Value::str(self.course.slug.as_str())); + course.insert_some( + "institution", + self.course + .institution + .as_ref() + .map(|s| Value::str(s.as_str())), + ); + course.insert( + "instructors", + Value::Array( + self.course + .instructors + .iter() + .map(|s| Value::str(s.as_str())) + .collect(), + ), + ); + root.insert("course", course); + + let a = &self.assessment; + let mut assessment = Value::dict(); + assessment.insert("id", Value::str(a.id.as_str())); + assessment.insert("title", Value::str(a.title.as_str())); + assessment.insert("term", Value::str(a.term.as_str())); + assessment.insert("kind", Value::str(a.kind.as_str())); + assessment.insert("platform", Value::str(a.platform.as_str())); + assessment.insert_some("date", a.date.as_ref().map(|s| Value::str(s.as_str()))); + assessment.insert_some( + "date-parts", + a.date_parts.map(|d| { + let mut parts = Value::dict(); + parts.insert("year", Value::Int(i64::from(d.year))); + parts.insert("month", Value::Int(i64::from(d.month))); + parts.insert("day", Value::Int(i64::from(d.day))); + parts + }), + ); + assessment.insert_some("minutes-allowed", a.minutes_allowed.map(Value::Float)); + assessment.insert_some( + "instructions", + a.instructions.as_ref().map(|s| markup_value(s, content)), + ); + assessment.insert_some("notes", a.notes.as_ref().map(|s| markup_value(s, content))); + root.insert("assessment", assessment); + + let mut form = Value::dict(); + form.insert("id", Value::str(self.form.id.as_str())); + // Seeds exceed `i64` range in practice, so they travel as strings. A + // template prints them for provenance and never does arithmetic on them. + form.insert("seed", Value::str(self.form.seed.to_string())); + form.insert("shuffle-items", Value::Bool(self.form.shuffle_items)); + form.insert("shuffle-options", Value::Bool(self.form.shuffle_options)); + form.insert("count", Value::Int(self.form.count as i64)); + root.insert("form", form); + + let t = &self.totals; + let mut totals = Value::dict(); + totals.insert("questions", Value::Int(t.questions as i64)); + totals.insert("scored", Value::Int(t.scored as i64)); + totals.insert("bonus", Value::Int(t.bonus as i64)); + totals.insert("points", Value::Float(t.points)); + totals.insert("bonus-points", Value::Float(t.bonus_points)); + totals.insert( + "level-counts", + Value::Dict( + t.level_counts + .iter() + .map(|(level, count)| (level.to_string(), Value::Int(*count as i64))) + .collect(), + ), + ); + root.insert("totals", totals); + + if !self.objectives.is_empty() { + root.insert( + "objectives", + Value::Dict( + self.objectives + .iter() + .map(|(id, objective)| { + let mut entry = Value::dict(); + entry.insert("text", markup_value(&objective.text, content)); + entry.insert_some( + "unit", + objective.unit.as_ref().map(|s| Value::str(s.as_str())), + ); + (id.clone(), entry) + }) + .collect(), + ), + ); + } + + if !self.stimuli.is_empty() { + root.insert( + "stimuli", + Value::Dict( + self.stimuli + .iter() + .map(|(id, stimulus)| (id.clone(), stimulus_value(stimulus, content))) + .collect(), + ), + ); + } + + root.insert( + "dropped", + Value::Array( + self.dropped + .iter() + .map(|n| Value::Int(i64::from(*n))) + .collect(), + ), + ); + + root.insert( + "extra", + Value::Dict( + self.extra + .iter() + .map(|(key, val)| (key.clone(), value::from_yaml(val, false))) + .collect(), + ), + ); + + root + } + + /// One question, as a Typst dictionary. + /// + /// # Arguments + /// + /// * `index` - which question. + /// * `config` - governs whether markup becomes content blocks or strings. + /// + /// # Returns + /// + /// The Typst value, or `None` when the index is out of range. + pub fn question_value(&self, index: usize, config: &RenderConfig) -> Option { + let question = self.questions.get(index)?; + Some(question_value(question, config)) + } + + /// Every question, as a Typst array. + /// + /// # Arguments + /// + /// * `config` - governs whether markup becomes content blocks or strings. + pub fn questions_value(&self, config: &RenderConfig) -> Value { + Value::Array( + self.questions + .iter() + .map(|q| question_value(q, config)) + .collect(), + ) + } + + /// The whole payload, as one Typst dictionary. + /// + /// # Arguments + /// + /// * `config` - governs whether markup becomes content blocks or strings. + pub fn data_value(&self, config: &RenderConfig) -> Value { + let mut root = self.meta_value(config); + root.insert("questions", self.questions_value(config)); + root + } + + /// The payload as pretty-printed JSON. + /// + /// For a template that prefers `json("questions.json")`, which is how a + /// hand-written Typst exam usually already reads its data. Note that JSON has + /// no content type, so a template consuming this has to `eval` the markup + /// fields; setting `content: str` in the render config keeps the Typst path + /// and the JSON path shaped identically. + /// + /// # Returns + /// + /// The JSON text. + /// + /// # Errors + /// + /// Returns [`Error::Other`](crate::error::Error::Other) if serialization + /// fails, which would mean a non-finite float reached the payload. + pub fn to_json(&self) -> Result { + serde_json::to_string_pretty(self).map_err(crate::error::Error::other) + } +} + +/// Builds one question dictionary. +fn question_value(question: &Question, config: &RenderConfig) -> Value { + let content = config.content.is_content(); + let mut root = Value::dict(); + + root.insert("number", Value::Int(i64::from(question.number))); + root.insert("position", Value::Int(question.position as i64)); + root.insert_some("uid", question.uid.as_ref().map(|s| Value::str(s.as_str()))); + root.insert_some( + "title", + question.title.as_ref().map(|t| markup_value(t, content)), + ); + root.insert_some("points", question.points.map(Value::Float)); + root.insert("bonus", Value::Bool(question.bonus)); + root.insert_some("level", question.level.map(|l| Value::Int(i64::from(l)))); + root.insert_some( + "level-name", + question.level_name.as_ref().map(|s| Value::str(s.as_str())), + ); + root.insert("format", Value::str(question.format.as_str())); + root.insert("multi-select", Value::Bool(question.multi_select)); + root.insert("stem", markup_value(&question.stem, content)); + root.insert_some( + "stimulus-id", + question + .stimulus_id + .as_ref() + .map(|s| Value::str(s.as_str())), + ); + root.insert_some( + "stimulus", + question + .stimulus + .as_ref() + .map(|s| stimulus_value(s, content)), + ); + + root.insert( + "options", + Value::Array( + question + .options + .iter() + .map(|o| option_value(o, content)) + .collect(), + ), + ); + + root.insert_some( + "key", + question + .key + .as_ref() + .map(|letters| Value::Array(letters.iter().map(|s| Value::str(s.as_str())).collect())), + ); + root.insert_some( + "credit-overrides", + question.credit_overrides.as_ref().map(|map| { + Value::Dict( + map.iter() + .map(|(letter, credit)| (letter.clone(), Value::Float(*credit))) + .collect(), + ) + }), + ); + + if !question.learning_objectives.is_empty() { + root.insert( + "learning-objectives", + Value::Array( + question + .learning_objectives + .iter() + .map(|s| Value::str(s.as_str())) + .collect(), + ), + ); + } + if !question.topics.is_empty() { + root.insert( + "topics", + Value::Array( + question + .topics + .iter() + .map(|s| Value::str(s.as_str())) + .collect(), + ), + ); + } + if !question.assets.is_empty() { + root.insert( + "assets", + Value::Array( + question + .assets + .iter() + .map(|a| { + let mut asset = Value::dict(); + asset.insert("path", Value::str(a.path.as_str())); + asset.insert_some("alt", a.alt.as_ref().map(|s| Value::str(s.as_str()))); + asset.insert_some( + "caption", + a.caption.as_ref().map(|c| markup_value(c, content)), + ); + asset + }) + .collect(), + ), + ); + } + root.insert_some("design", question.design.as_ref().map(numeric_map)); + root.insert_some( + "calibration", + question.calibration.as_ref().map(numeric_map), + ); + + root +} + +/// Builds one option dictionary. +fn option_value(opt: &Opt, content: bool) -> Value { + let mut root = Value::dict(); + root.insert("letter", Value::str(opt.letter.as_str())); + root.insert("position", Value::Int(opt.position as i64)); + root.insert_some( + "source-letter", + opt.source_letter.as_ref().map(|s| Value::str(s.as_str())), + ); + root.insert("text", markup_value(&opt.text, content)); + root.insert_some("correct", opt.correct.map(Value::Bool)); + root.insert_some("credit", opt.credit.map(Value::Float)); + root.insert_some("defensible", opt.defensible.map(Value::Bool)); + root.insert_some( + "explanation", + opt.explanation.as_ref().map(|s| markup_value(s, content)), + ); + root.insert_some( + "misconception", + opt.misconception.as_ref().map(|s| markup_value(s, content)), + ); + root.insert_some( + "error-type", + opt.error_type.as_ref().map(|s| Value::str(s.as_str())), + ); + root.insert_some( + "feedback", + opt.feedback.as_ref().map(|s| markup_value(s, content)), + ); + root +} + +/// Builds a stimulus dictionary. +fn stimulus_value(stimulus: &Stimulus, content: bool) -> Value { + let mut root = Value::dict(); + root.insert("body", markup_value(&stimulus.body, content)); + root.insert_some( + "caption", + stimulus.caption.as_ref().map(|c| markup_value(c, content)), + ); + root.insert_some( + "asset", + stimulus.asset.as_ref().map(|s| Value::str(s.as_str())), + ); + root +} + +/// Builds a dictionary of floats. +fn numeric_map(map: &BTreeMap) -> Value { + Value::Dict( + map.iter() + .map(|(key, val)| (key.clone(), Value::Float(*val))) + .collect(), + ) +} + +/// Emits authored markup as either a content block or a quoted string. +fn markup_value(source: &str, content: bool) -> Value { + if content { + Value::content(source) + } else { + Value::str(source) + } +} + +/// Emits the `questions` slot: one call per question. +/// +/// # Arguments +/// +/// * `payload` - the built payload. +/// * `config` - supplies the function name and the content mode. +/// +/// # Returns +/// +/// Typst source. Each question is passed as a single positional dictionary rather +/// than as named arguments, so a template's function signature does not have to +/// change every time a field is enabled or disabled in the config. +pub fn questions_body(payload: &Payload, config: &RenderConfig) -> String { + let mut out = String::new(); + for (index, question) in payload.questions.iter().enumerate() { + if index > 0 { + out.push('\n'); + } + out.push('#'); + out.push_str(&config.question_fn); + out.push('('); + out.push_str(&question_value(question, config).to_typst(0)); + out.push_str(")\n"); + } + out +} + +/// Emits the `meta` slot. +/// +/// # Arguments +/// +/// * `payload` - the built payload. +/// * `config` - supplies the binding name and the content mode. +pub fn meta_body(payload: &Payload, config: &RenderConfig) -> String { + format!( + "#let {} = {}\n", + config.meta_binding, + payload.meta_value(config).to_typst(0) + ) +} + +/// Emits the `data` slot. +/// +/// # Arguments +/// +/// * `payload` - the built payload. +/// * `config` - supplies the binding name and the content mode. +pub fn data_body(payload: &Payload, config: &RenderConfig) -> String { + format!( + "#let {} = {}\n", + config.data_binding, + payload.data_value(config).to_typst(0) + ) +} + +/// Checks emitted markup for problems a Typst compile would report obscurely. +/// +/// Content blocks carry authored markup through verbatim, which is what makes +/// `*bold*` work in a stem. The cost is that an unbalanced `[` in a stem becomes a +/// parse error somewhere downstream of the item that caused it, and the compiler +/// has no way to name the question. Naming it here is much cheaper than finding it +/// there. +/// +/// # Arguments +/// +/// * `payload` - the built payload. +/// * `config` - only content mode is consulted; string mode cannot have this +/// problem and returns nothing. +/// +/// # Returns +/// +/// One message per suspect field. Advisory: these are warnings, since a stem may +/// legitimately contain Typst code with brackets this scan cannot follow. +pub fn check(payload: &Payload, config: &RenderConfig) -> Vec { + if !config.content.is_content() { + return Vec::new(); + } + let mut issues = Vec::new(); + for question in &payload.questions { + let label = format!( + "question {}{}", + question.number, + question + .uid + .as_ref() + .map(|u| format!(" ({u})")) + .unwrap_or_default() + ); + if !balanced(&question.stem) { + issues.push(format!("{label}: the stem has unbalanced square brackets")); + } + for opt in &question.options { + if !balanced(&opt.text) { + issues.push(format!( + "{label}, option {}: unbalanced square brackets", + opt.letter + )); + } + } + } + issues +} + +/// Whether square brackets balance, ignoring escaped ones and raw spans. +fn balanced(source: &str) -> bool { + let mut depth = 0i32; + let mut chars = source.chars().peekable(); + let mut in_raw = false; + while let Some(ch) = chars.next() { + match ch { + '\\' => { + // An escaped bracket is literal text, not a delimiter. + chars.next(); + } + '`' => in_raw = !in_raw, + '[' if !in_raw => depth += 1, + ']' if !in_raw => { + depth -= 1; + if depth < 0 { + return false; + } + } + _ => {} + } + } + depth == 0 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bracket_balance_ignores_escapes_and_raw_spans() { + assert!(balanced("plain text")); + assert!(balanced("#text(red)[ok]")); + assert!(balanced("an escaped \\[ bracket")); + assert!(balanced("code `a[0]` inline")); + assert!(!balanced("#text(red)[oops")); + assert!(!balanced("closing ] first")); + } +} diff --git a/src/export/typst/template.rs b/src/export/typst/template.rs new file mode 100644 index 0000000..e3eedea --- /dev/null +++ b/src/export/typst/template.rs @@ -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/-.typ`, for a one-off layout +//! 3. `templates/.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::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::>() + .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, +} + +/// 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 { + 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