feat: improve typst handling

This commit is contained in:
2026-08-06 16:45:50 -04:00
parent 09c3222f8d
commit 07e3131f17
19 changed files with 4148 additions and 416 deletions
+746
View File
@@ -0,0 +1,746 @@
//! What gets emitted, and in what shape.
//!
//! This module holds the knobs that used to be `format!` calls. The split is
//! deliberate: a template decides how a question *looks*, and this config decides
//! what the template is *told*. Anything you can express by rearranging boxes
//! belongs in the template, not here.
//!
//! The one setting that is not cosmetic is [`Reveal`]. It controls whether the
//! payload contains the answer at all, and the reason it is a config value rather
//! than a template concern is that a template cannot be trusted with it. If the
//! exam paper's payload carries `correct: true`, then every future edit to that
//! template is one `if` statement away from printing the key, and the failure mode
//! is discovered by a room full of students. Withholding the field is the only
//! version of this that stays correct under editing.
//!
//! Config is resolved in three layers, each overriding the last: the built-in
//! defaults for the variant, the `defaults:` block of `templates/typst.yaml`, and
//! that file's `variants:` block. `coursebank template config` writes the whole
//! resolved thing out so there is no guessing about what applied.
use std::collections::BTreeMap;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
/// Which document is being produced.
///
/// A variant is not a style. It is a different set of facts: the paper withholds
/// the key, the key withholds the questions, and the answer sheet needs only the
/// option counts. Each has its own template and its own default [`Reveal`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Variant {
/// The question paper a student writes on.
Exam,
/// The grader's answer key.
Key,
/// A bubble sheet matching the form.
AnswerSheet,
}
impl Variant {
/// Every variant, in the order `export` writes them.
pub const ALL: [Variant; 3] = [Variant::Exam, Variant::Key, Variant::AnswerSheet];
/// The token used on the command line, in config keys, and in file names.
pub fn as_str(self) -> &'static str {
match self {
Variant::Exam => "exam",
Variant::Key => "key",
Variant::AnswerSheet => "answer-sheet",
}
}
/// The variant for a token.
///
/// # Arguments
///
/// * `s` - the token, e.g. `"answer-sheet"`.
///
/// # Returns
///
/// The variant.
///
/// # Errors
///
/// Returns [`Error::Usage`] naming the valid tokens.
pub fn parse(s: &str) -> Result<Variant> {
let normalized = s.trim().to_ascii_lowercase().replace('_', "-");
Variant::ALL
.iter()
.copied()
.find(|v| v.as_str() == normalized)
.ok_or_else(|| {
Error::usage(format!(
"unknown template variant `{s}`; expected one of {}",
Variant::ALL
.iter()
.map(|v| v.as_str())
.collect::<Vec<_>>()
.join(", ")
))
})
}
/// The file name this variant's template is looked up under.
pub fn template_file(self) -> String {
format!("{}.typ", self.as_str())
}
/// The suffix appended to an exported file's stem.
///
/// The paper gets no suffix because it is the thing you print most often and
/// `exam-2-A.typ` reads better than `exam-2-A-exam.typ`.
pub fn suffix(self) -> &'static str {
match self {
Variant::Exam => "",
Variant::Key => "-key",
Variant::AnswerSheet => "-answer-sheet",
}
}
}
/// How much of the answer side of an item reaches the payload.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Reveal {
/// Nothing. No `correct`, no `credit`, no keyed letters, no rationale. What a
/// student form must use.
Nothing,
/// Which options are correct, what credit each earns, and any recorded
/// partial-credit overrides. Enough to grade with.
Key,
/// Everything, including instructor rationale, targeted misconceptions, and
/// the record's private notes. For a review copy that never leaves your desk.
Everything,
}
impl Reveal {
/// Whether keyed letters, `correct`, and `credit` are emitted.
pub fn shows_key(self) -> bool {
self != Reveal::Nothing
}
/// Whether rationale, misconceptions, and private notes are emitted.
pub fn shows_rationale(self) -> bool {
self == Reveal::Everything
}
}
/// How option labels are generated.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum LetterStyle {
/// `A`, `B`, `C`.
Upper,
/// `a`, `b`, `c`.
Lower,
/// `1`, `2`, `3`.
Numeric,
/// `i`, `ii`, `iii`.
Roman,
/// No label; the template supplies its own, usually via `numbering`.
Nothing,
}
impl LetterStyle {
/// The label for a zero-based printed position.
///
/// # Arguments
///
/// * `position` - the printed position, counting from zero.
///
/// # Returns
///
/// The label, or an empty string for [`LetterStyle::Nothing`].
pub fn label(self, position: usize) -> String {
match self {
LetterStyle::Upper => alphabetic(position, true),
LetterStyle::Lower => alphabetic(position, false),
LetterStyle::Numeric => (position + 1).to_string(),
LetterStyle::Roman => roman(position + 1),
LetterStyle::Nothing => String::new(),
}
}
}
/// A spreadsheet-style label: `A`..`Z`, then `AA`.
///
/// Eight options is the schema's ceiling, so the second character is unreachable
/// in practice. It is here so that raising that ceiling does not silently produce
/// `[` as an option label, which is what `b'A' + 26` gives you.
fn alphabetic(position: usize, upper: bool) -> String {
let base = if upper { b'A' } else { b'a' };
let mut n = position;
let mut letters = Vec::new();
loop {
letters.push((base + (n % 26) as u8) as char);
if n < 26 {
break;
}
n = n / 26 - 1;
}
letters.iter().rev().collect()
}
/// A lowercase Roman numeral for a one-based position.
fn roman(mut n: usize) -> String {
const TABLE: [(usize, &str); 13] = [
(1000, "m"),
(900, "cm"),
(500, "d"),
(400, "cd"),
(100, "c"),
(90, "xc"),
(50, "l"),
(40, "xl"),
(10, "x"),
(9, "ix"),
(5, "v"),
(4, "iv"),
(1, "i"),
];
let mut out = String::new();
for (value, numeral) in TABLE {
while n >= value {
out.push_str(numeral);
n -= value;
}
}
out
}
/// How markup-bearing fields are emitted.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ContentMode {
/// As content blocks, `[...]`, which Typst parses at compile time. Errors
/// point at the generated file, and no `eval` is needed.
Content,
/// As quoted strings, which a template evaluates with
/// `eval(q.stem, mode: "markup")`. Required if the same payload is also
/// consumed as JSON, since JSON has no content type.
Str,
}
impl ContentMode {
/// Whether markup becomes a content block rather than a string.
pub fn is_content(self) -> bool {
self == ContentMode::Content
}
}
/// Whether a shared stimulus travels with each item that uses it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum StimulusMode {
/// Repeat the body with every item that references it. Wasteful on paper, but
/// a student should never have to turn a page to find the passage a question
/// is about.
Inline,
/// Emit only the id on the item, and the bodies once in the metadata under
/// `stimuli`. For a template that prints a testlet header above its group.
Shared,
/// Leave stimuli out.
Omit,
}
/// Which optional per-question fields are emitted.
///
/// Everything here defaults on except the two heavy ones. Turning a field off is
/// about payload noise, not secrecy — [`Reveal`] is what governs secrecy.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Fields {
/// The item's global id, `bank::item`. Useful printed in small grey type on a
/// review copy; never wanted on a student form.
#[serde(default = "yes")]
pub uid: bool,
/// The item's short title.
#[serde(default = "yes")]
pub title: bool,
/// Point value.
#[serde(default = "yes")]
pub points: bool,
/// Cognitive level, as both a number and a name.
#[serde(default = "yes")]
pub level: bool,
/// Learning objective ids.
#[serde(default = "yes")]
pub objectives: bool,
/// Topic tags.
#[serde(default = "yes")]
pub topics: bool,
/// Figures and data files attached to the item.
#[serde(default = "yes")]
pub assets: bool,
/// The letter the option carries in the bank, before shuffling. On a key this
/// is what lets you find the option in the YAML.
#[serde(default = "yes")]
pub source_letters: bool,
/// The authored predictions in the item's `design` block.
#[serde(default = "no")]
pub design: bool,
/// Pooled statistics from previous administrations.
#[serde(default = "no")]
pub calibration: bool,
}
impl Default for Fields {
fn default() -> Fields {
Fields {
uid: true,
title: true,
points: true,
level: true,
objectives: true,
topics: true,
assets: true,
source_letters: true,
design: false,
calibration: false,
}
}
}
/// The resolved configuration for rendering one variant.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RenderConfig {
/// A template path that overrides the usual lookup.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub template: Option<PathBuf>,
/// The Typst function the `questions` slot calls, once per item, with a
/// single dictionary argument. The template defines it.
#[serde(default = "default_question_fn")]
pub question_fn: String,
/// The binding the `meta` slot declares.
#[serde(default = "default_meta_binding")]
pub meta_binding: String,
/// The binding the `data` slot declares.
#[serde(default = "default_data_binding")]
pub data_binding: String,
/// How much of the answer side to include.
#[serde(default = "default_reveal")]
pub reveal: Reveal,
/// How option labels are generated.
#[serde(default = "default_letters")]
pub letters: LetterStyle,
/// How markup is emitted.
#[serde(default = "default_content")]
pub content: ContentMode,
/// How shared stimuli are handled.
#[serde(default = "default_stimulus")]
pub stimulus: StimulusMode,
/// Which optional fields to include.
#[serde(default)]
pub fields: Fields,
/// Whether questions carry the number recorded in the assessment record
/// rather than their printed position.
///
/// Keep this on. The recorded number is the join key to every grading export
/// and response row; renumbering after a drop breaks that join silently, and
/// the symptom is item statistics attributed to the wrong question.
#[serde(default = "yes")]
pub number_from_record: bool,
/// Anything else you want the template to see, carried through untouched.
///
/// This is the escape hatch that keeps the crate out of your layout
/// decisions. Tier colours, a `show-solutions` flag, a font stack, a watermark
/// string: put it here and read it from `extra` in the template.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub extra: BTreeMap<String, serde_yaml_ng::Value>,
}
impl RenderConfig {
/// The built-in configuration for a variant.
///
/// # Arguments
///
/// * `variant` - which document.
///
/// # Returns
///
/// A configuration matching what the bundled template for that variant
/// expects.
pub fn for_variant(variant: Variant) -> RenderConfig {
let mut config = RenderConfig {
template: None,
question_fn: default_question_fn(),
meta_binding: default_meta_binding(),
data_binding: default_data_binding(),
reveal: Reveal::Nothing,
letters: LetterStyle::Upper,
content: ContentMode::Content,
stimulus: StimulusMode::Inline,
fields: Fields::default(),
number_from_record: true,
extra: BTreeMap::new(),
};
match variant {
Variant::Exam => {
// The paper must not carry the key in any form.
config.reveal = Reveal::Nothing;
config.fields.uid = false;
config.fields.source_letters = false;
config.fields.objectives = false;
}
Variant::Key => {
config.reveal = Reveal::Everything;
config.stimulus = StimulusMode::Omit;
}
Variant::AnswerSheet => {
config.reveal = Reveal::Nothing;
config.stimulus = StimulusMode::Omit;
config.fields = Fields {
uid: false,
title: false,
points: true,
level: false,
objectives: false,
topics: false,
assets: false,
source_letters: false,
design: false,
calibration: false,
};
}
}
config
}
/// Renders the configuration as YAML.
///
/// For `coursebank template config --resolved`, which is the answer to "which
/// layer won?" — a question that is otherwise answered by reading three files
/// and guessing.
///
/// # Returns
///
/// YAML text.
///
/// # Errors
///
/// Returns [`Error::Other`](crate::error::Error::Other) if serialization
/// fails.
pub fn to_yaml(&self) -> Result<String> {
serde_yaml_ng::to_string(self).map_err(Error::other)
}
}
/// The file a course's Typst configuration lives in, under `templates/`.
pub const CONFIG_FILE: &str = "typst.yaml";
/// A course's Typst export configuration.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ConfigFile {
/// Schema version this file targets.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub schema_version: Option<String>,
/// Overrides applied to every variant.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub defaults: Option<Overrides>,
/// Overrides applied to one variant, on top of `defaults`.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub variants: BTreeMap<Variant, Overrides>,
}
impl ConfigFile {
/// Resolves the configuration for one variant.
///
/// # Arguments
///
/// * `variant` - which document.
///
/// # Returns
///
/// The built-in defaults with the file's `defaults` and then its
/// variant-specific block applied.
pub fn resolve(&self, variant: Variant) -> RenderConfig {
let mut config = RenderConfig::for_variant(variant);
if let Some(defaults) = &self.defaults {
defaults.apply(&mut config);
}
if let Some(specific) = self.variants.get(&variant) {
specific.apply(&mut config);
}
config
}
}
/// A partial [`RenderConfig`]: every field optional, so a config file can say one
/// thing without restating the rest.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Overrides {
/// See [`RenderConfig::template`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub template: Option<PathBuf>,
/// See [`RenderConfig::question_fn`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub question_fn: Option<String>,
/// See [`RenderConfig::meta_binding`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub meta_binding: Option<String>,
/// See [`RenderConfig::data_binding`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub data_binding: Option<String>,
/// See [`RenderConfig::reveal`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reveal: Option<Reveal>,
/// See [`RenderConfig::letters`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub letters: Option<LetterStyle>,
/// See [`RenderConfig::content`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content: Option<ContentMode>,
/// See [`RenderConfig::stimulus`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stimulus: Option<StimulusMode>,
/// See [`RenderConfig::fields`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fields: Option<Fields>,
/// See [`RenderConfig::number_from_record`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub number_from_record: Option<bool>,
/// Merged key by key into [`RenderConfig::extra`] rather than replacing it, so
/// a variant can add one flag without repeating the shared block.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub extra: BTreeMap<String, serde_yaml_ng::Value>,
}
impl Overrides {
/// Applies these overrides in place.
///
/// # Arguments
///
/// * `config` - the configuration to modify.
pub fn apply(&self, config: &mut RenderConfig) {
if let Some(v) = &self.template {
config.template = Some(v.clone());
}
if let Some(v) = &self.question_fn {
config.question_fn = v.clone();
}
if let Some(v) = &self.meta_binding {
config.meta_binding = v.clone();
}
if let Some(v) = &self.data_binding {
config.data_binding = v.clone();
}
if let Some(v) = self.reveal {
config.reveal = v;
}
if let Some(v) = self.letters {
config.letters = v;
}
if let Some(v) = self.content {
config.content = v;
}
if let Some(v) = self.stimulus {
config.stimulus = v;
}
if let Some(v) = &self.fields {
config.fields = v.clone();
}
if let Some(v) = self.number_from_record {
config.number_from_record = v;
}
for (key, value) in &self.extra {
config.extra.insert(key.clone(), value.clone());
}
}
}
/// The commented starter config written by `coursebank template config`.
///
/// Written as text rather than serialized from [`ConfigFile`] because the comments
/// are the useful part, and a serializer drops them.
pub const CONFIG_TEMPLATE: &str = r#"# Typst export configuration.
#
# Layers, each overriding the last:
# 1. the built-in defaults for the variant
# 2. `defaults:` below
# 3. `variants:` below
#
# `coursebank template config --resolved` prints what a variant actually ends up
# with, which is the quickest way to check whether a key landed where you meant.
schema_version: "1.0"
defaults:
# The Typst function the `questions` slot calls once per item, with one
# dictionary argument. Your template defines it.
question_fn: render-question
# `content` emits stems as `[...]`, which Typst parses directly.
# `str` emits them as strings for a template that calls `eval`, and is what you
# want if the same payload is also read as JSON.
content: content
# upper | lower | numeric | roman | nothing
letters: upper
# Anything here is passed through untouched and shows up as `extra` in the
# payload. This is where layout decisions belong.
#
# Note the spelling shift: keys above are coursebank's and are snake_case like
# every other coursebank YAML file, but keys under `extra` are yours and reach
# Typst verbatim, so they use Typst's hyphens.
extra:
accent: '#017ab9'
font: 'Libertinus Serif'
variants:
exam:
# Leave this alone unless you are certain. `nothing` is what keeps the answer
# out of the student's copy; a template cannot leak a field it was never given.
reveal: nothing
extra:
show-solutions: false
key:
# nothing | key | everything
reveal: everything
extra:
show-solutions: true
answer-sheet:
reveal: nothing
"#;
/// Serde default: `true`.
fn yes() -> bool {
true
}
/// Serde default: `false`.
fn no() -> bool {
false
}
/// Serde default for [`RenderConfig::question_fn`].
fn default_question_fn() -> String {
"render-question".to_string()
}
/// Serde default for [`RenderConfig::meta_binding`].
fn default_meta_binding() -> String {
"cb-meta".to_string()
}
/// Serde default for [`RenderConfig::data_binding`].
fn default_data_binding() -> String {
"cb-data".to_string()
}
/// Serde default for [`RenderConfig::reveal`].
fn default_reveal() -> Reveal {
Reveal::Nothing
}
/// Serde default for [`RenderConfig::letters`].
fn default_letters() -> LetterStyle {
LetterStyle::Upper
}
/// Serde default for [`RenderConfig::content`].
fn default_content() -> ContentMode {
ContentMode::Content
}
/// Serde default for [`RenderConfig::stimulus`].
fn default_stimulus() -> StimulusMode {
StimulusMode::Inline
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn variant_tokens_round_trip() {
for variant in Variant::ALL {
assert_eq!(Variant::parse(variant.as_str()).unwrap(), variant);
}
// Underscores are tolerated because people type them.
assert_eq!(
Variant::parse("answer_sheet").unwrap(),
Variant::AnswerSheet
);
assert!(Variant::parse("bubbles").is_err());
}
#[test]
fn the_exam_variant_never_reveals_the_key_by_default() {
// This is the one default in this module that is a correctness property
// rather than a preference.
let config = RenderConfig::for_variant(Variant::Exam);
assert_eq!(config.reveal, Reveal::Nothing);
assert!(!config.reveal.shows_key());
}
#[test]
fn letters_are_generated_per_style() {
assert_eq!(LetterStyle::Upper.label(0), "A");
assert_eq!(LetterStyle::Upper.label(3), "D");
assert_eq!(LetterStyle::Lower.label(1), "b");
assert_eq!(LetterStyle::Numeric.label(4), "5");
assert_eq!(LetterStyle::Roman.label(3), "iv");
assert_eq!(LetterStyle::Nothing.label(2), "");
}
#[test]
fn letters_past_z_do_not_run_off_the_alphabet() {
// `b'A' + 26` is `[`. Anything that produced that would be a silent
// corruption of an option label rather than an error.
assert_eq!(LetterStyle::Upper.label(26), "AA");
assert_eq!(LetterStyle::Upper.label(25), "Z");
}
#[test]
fn config_layers_override_in_order() {
let file: ConfigFile = serde_yaml_ng::from_str(
"defaults:\n letters: lower\n extra:\n a: 1\nvariants:\n key:\n letters: \
numeric\n extra:\n b: 2\n",
)
.unwrap();
let exam = file.resolve(Variant::Exam);
assert_eq!(exam.letters, LetterStyle::Lower);
let key = file.resolve(Variant::Key);
assert_eq!(key.letters, LetterStyle::Numeric);
// `extra` merges rather than replacing, so `a` survives.
assert!(key.extra.contains_key("a"));
assert!(key.extra.contains_key("b"));
}
#[test]
fn an_empty_config_file_changes_nothing() {
let file = ConfigFile::default();
for variant in Variant::ALL {
assert_eq!(file.resolve(variant), RenderConfig::for_variant(variant));
}
}
#[test]
fn the_starter_config_parses_and_keeps_the_exam_closed() {
let file: ConfigFile = serde_yaml_ng::from_str(CONFIG_TEMPLATE).unwrap();
assert_eq!(file.resolve(Variant::Exam).reveal, Reveal::Nothing);
assert_eq!(file.resolve(Variant::Key).reveal, Reveal::Everything);
}
}