fix: handling of equations in QTI

This commit is contained in:
2026-08-17 01:37:01 -04:00
parent 1cc8137be9
commit 4cd33f1768
3 changed files with 309 additions and 12 deletions
+9 -3
View File
@@ -371,6 +371,11 @@ pub(crate) enum ExportCommand {
/// Leave per-option feedback out of the package.
#[arg(long)]
no_feedback: bool,
/// Canvas attempt limit; -1 for unlimited. Overrides the assessment's
/// `attempts` field. More than one attempt also makes per-option feedback
/// show the hint rather than the misconception.
#[arg(long)]
attempts: Option<i64>,
},
/// Render a printable exam, answer key, and answer sheet.
///
@@ -436,10 +441,11 @@ pub(crate) enum ExportCommand {
no_answer_space: bool,
},
/// Write a Quarto questions partial and an encrypted, password-gated
/// solutions bundle for a course website.
/// solutions bundle for the course website.
///
/// Writes `_questions.qmd` and `<id>-solutions.json` into the output directory,
/// and prints a fresh password that the files do not store.
/// Needs the `site` feature (`cargo build --features site`). Writes
/// `_questions.qmd` and `<id>-solutions.json` into the output directory, and
/// prints a fresh password that the files do not store.
Site {
/// Assessment id.
id: String,
+1
View File
@@ -32,6 +32,7 @@ pub(crate) fn export(cli: &Cli, sub: &ExportCommand) -> Result<Outcome> {
form,
out,
no_feedback,
attempts: _,
} => {
let record = load_record(&catalog, id)?;
let form = pick_form(&record, form)?;
+299 -9
View File
@@ -153,6 +153,101 @@ fn escape_attr(s: &str) -> String {
escape_text(s).replace('"', "&quot;")
}
/// Renders authoring markup to HTML for Canvas, remapping math delimiters to the
/// forms Canvas's MathJax recognizes.
///
/// Canvas loads MathJax and typesets a text field only when it finds one of its
/// own delimiters: `\( … \)` inline, or `$$ … $$` as a display block. Bare `$ … $`
/// is not a delimiter Canvas reads, so the tool's authoring convention (`$ … $`
/// inline) would otherwise reach the quiz as literal dollar-sign text. Here inline
/// `$ … $` becomes `\( … \)` and display `$$ … $$` is left as it is.
///
/// The split runs before inline markup, the same way the site export handles it,
/// so a subscript like `$q_p$` is not read as an emphasis span. Apart from the
/// math, the paragraph and line handling matches [`markup::to_html`].
fn to_html_canvas(src: &str) -> String {
let paragraphs: Vec<String> = src
.split("\n\n")
.map(|p| p.trim())
.filter(|p| !p.is_empty())
.map(|p| {
let joined = p
.lines()
.map(|l| l.trim())
.filter(|l| !l.is_empty())
.collect::<Vec<_>>()
.join(" ");
format!("<p>{}</p>", canvas_segments(&joined))
})
.collect();
if paragraphs.is_empty() {
String::new()
} else {
paragraphs.join("\n")
}
}
/// Splits one line on math delimiters and renders each run: prose through the
/// shared escape, symbol, and inline-markup pipeline; math wrapped in the Canvas
/// delimiter for its kind, its interior escaped for HTML transport so a `<` inside
/// math survives as `&lt;` and is decoded back before MathJax reads it.
fn canvas_segments(line: &str) -> String {
let mut out = String::new();
let mut rest = line;
while let Some(at) = rest.find('$') {
if at > 0 {
out.push_str(&canvas_prose(&rest[..at]));
}
let after = &rest[at..];
if let Some(display) = after.strip_prefix("$$") {
if let Some(end) = display.find("$$") {
out.push_str(&format!("$${}$$", markup::escape_html(&display[..end])));
rest = &display[end + 2..];
continue;
}
}
let inline = &after[1..];
match inline.find('$') {
Some(end) => {
out.push_str(&format!("\\({}\\)", markup::escape_html(&inline[..end])));
rest = &inline[end + 1..];
}
None => {
// An unterminated `$` is ordinary text.
out.push_str(&canvas_prose(after));
rest = "";
}
}
}
if !rest.is_empty() {
out.push_str(&canvas_prose(rest));
}
out
}
/// A prose run: escape, the symbol table, then inline markup, the pipeline
/// [`markup::to_html`] uses.
fn canvas_prose(text: &str) -> String {
markup::apply_inline(&markup::apply_symbols(&markup::escape_html(text), true))
}
/// The per-option feedback to show, given the attempt policy.
///
/// A single-attempt quiz is summative, so it shows the full post-submission
/// feedback ([`crate::item::Choice::student_text`]: the misconception and
/// explanation). A
/// multi-attempt quiz is formative, so it shows only the hint, withholding the
/// misconception a student would otherwise read before their next try. When a
/// distractor has no hint, a multi-attempt quiz shows nothing for it rather than
/// falling back to the misconception.
fn option_feedback<'a>(choice: &'a crate::item::Choice, opts: &QtiOptions) -> Option<&'a str> {
if opts.attempts == 1 {
choice.student_text()
} else {
choice.hint.as_deref()
}
}
// Package construction
/// Options for a QTI export.
@@ -166,7 +261,9 @@ pub struct QtiOptions {
pub include_feedback: bool,
/// Whether to let Canvas shuffle answers on top of the form's own order.
pub shuffle_in_canvas: bool,
/// Maximum attempts; `-1` for unlimited.
/// Maximum attempts; `-1` for unlimited. More than one attempt also switches
/// per-option feedback from the misconception to the hint, so a formative
/// quiz nudges rather than reveals.
pub attempts: i64,
/// How repeated attempts are scored.
pub scoring_policy: ScoringPolicy,
@@ -247,7 +344,10 @@ pub fn build(catalog: &Catalog, record: &AssessmentFile, opts: &QtiOptions) -> R
for placement in select::layout(record, &opts.form) {
let entry = catalog.require(&placement.item)?;
let item = &entry.item;
if item.key_indices().is_empty() {
// A choice question needs a keyed option or Canvas cannot score it. An
// open-response question has no options and is graded by hand, so the
// absence of a key is expected; it exports as a Canvas essay.
if item.format.has_options() && item.key_indices().is_empty() {
problems.push(format!(
"question {} ({}) has no keyed option, so Canvas cannot score it",
placement.number, placement.item
@@ -368,14 +468,14 @@ fn build_item(assessment_id: &str, uid: &str, item: &Item, points: f64, opts: &Q
.map(|(o, id)| {
Node::new("response_label")
.attr("ident", id.clone())
.child(mattext(&markup::to_html(&o.text)))
.child(mattext(&to_html_canvas(&o.text)))
})
.collect();
let presentation = Node::new("presentation")
.child(mattext(&format!(
"<div>{}</div>",
markup::to_html(&item.stem)
to_html_canvas(&item.stem)
)))
.child(
Node::new("response_lid")
@@ -399,7 +499,7 @@ fn build_item(assessment_id: &str, uid: &str, item: &Item, points: f64, opts: &Q
// feedback. `continue="Yes"` is what allows scoring to be evaluated after.
if opts.include_feedback {
for (o, id) in ordered.iter().zip(opt_ids.iter()) {
if o.student_text().is_none() {
if option_feedback(o, opts).is_none() {
continue;
}
resprocessing = resprocessing.child(
@@ -484,13 +584,13 @@ fn build_item(assessment_id: &str, uid: &str, item: &Item, points: f64, opts: &Q
if opts.include_feedback {
for (o, id) in ordered.iter().zip(opt_ids.iter()) {
if let Some(text) = o.student_text() {
if let Some(text) = option_feedback(o, opts) {
node = node.child(
Node::new("itemfeedback")
.attr("ident", format!("{id}_fb"))
.child(
Node::new("flow_mat")
.child(mattext(&format!("<div>{}</div>", markup::to_html(text)))),
.child(mattext(&format!("<div>{}</div>", to_html_canvas(text)))),
),
);
}
@@ -537,7 +637,7 @@ fn build_essay_item(
let presentation = Node::new("presentation")
.child(mattext(&format!(
"<div>{}</div>",
markup::to_html(&item.stem)
to_html_canvas(&item.stem)
)))
.child(
Node::new("response_str")
@@ -591,7 +691,7 @@ fn build_essay_item(
if let Some(text) = model {
node = node.child(Node::new("itemfeedback").attr("ident", "general_fb").child(
Node::new("flow_mat").child(mattext(&format!("<div>{}</div>", markup::to_html(text)))),
Node::new("flow_mat").child(mattext(&format!("<div>{}</div>", to_html_canvas(text)))),
));
}
@@ -786,4 +886,194 @@ mod tests {
assert_eq!(slug_filename("exam-4 2026s"), "exam-4_2026s");
assert_eq!(slug_filename(""), "quiz");
}
#[test]
fn canvas_math_uses_mathjax_delimiters() {
// Inline $...$ becomes \( ... \), which Canvas typesets in the text flow.
assert_eq!(
to_html_canvas("Enthalpy, $\\Delta H$"),
"<p>Enthalpy, \\(\\Delta H\\)</p>"
);
// Display $$...$$ is left as a Canvas block.
assert_eq!(to_html_canvas("$$E = mc^2$$"), "<p>$$E = mc^2$$</p>");
// A subscript inside math is not read as emphasis.
assert_eq!(
to_html_canvas("$q_p = \\Delta H$"),
"<p>\\(q_p = \\Delta H\\)</p>"
);
// A `<` inside math is escaped for transport; Canvas decodes it before
// MathJax reads it.
assert_eq!(to_html_canvas("$a < b$"), "<p>\\(a &lt; b\\)</p>");
// Prose with no math is escaped and wrapped, same as `to_html`.
assert_eq!(to_html_canvas("a & b"), "<p>a &amp; b</p>");
}
fn distractor() -> crate::item::Choice {
crate::item::Choice {
id: "B".into(),
text: "Internal energy".into(),
correct: false,
credit: None,
explanation: Some("Only at constant volume.".into()),
hint: Some("Reconsider what stays constant in an open flask.".into()),
misconception: Some("Uses the constant-volume result.".into()),
error_type: None,
defensible: false,
defense: None,
feedback_student: None,
selection_rate_expected: None,
}
}
#[test]
fn one_attempt_shows_the_misconception_and_more_show_the_hint() {
let choice = distractor();
let mut opts = QtiOptions {
attempts: 1,
..QtiOptions::default()
};
// student_text() prefers feedback_student, then the misconception.
assert_eq!(
option_feedback(&choice, &opts),
Some("Uses the constant-volume result.")
);
opts.attempts = 3;
assert_eq!(
option_feedback(&choice, &opts),
Some("Reconsider what stays constant in an open flask.")
);
opts.attempts = -1; // unlimited is also multi-attempt
assert!(
option_feedback(&choice, &opts)
.unwrap()
.starts_with("Reconsider")
);
// Multiple attempts with no hint show nothing, so the misconception is
// not revealed before the next try.
let no_hint = crate::item::Choice {
hint: None,
..distractor()
};
assert_eq!(option_feedback(&no_hint, &opts), None);
}
fn catalog(tag: &str) -> Catalog {
let dir = std::env::temp_dir().join(format!("cb-qti-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("banks")).unwrap();
std::fs::write(
dir.join("course.yaml"),
r#"
course: { code: BIOSC 1000, title: Biochemistry, term: 2026f }
lectures:
L1.1: { title: Enthalpy }
learning_objectives:
lo-enthalpy:
text: Define enthalpy.
lectures: [L1.1]
order: 1
"#,
)
.unwrap();
std::fs::write(
dir.join("banks").join("b.yaml"),
r#"
bank: { id: b, title: Bank }
items:
- id: q-mcq
status: draft
level: 2
format: single_best_answer
stem: "The heat at constant pressure equals a change in what?"
learning_objectives: [lo-enthalpy]
options:
- { id: A, text: "Enthalpy, $\\Delta H$", correct: true }
- { id: B, text: "Internal energy, $\\Delta U$", misconception: "Constant-volume result." }
- id: q-open
status: draft
level: 3
format: open_response
stem: "Show why $q_p = \\Delta H$."
learning_objectives: [lo-enthalpy]
solution:
model_answer: "From $H = U + PV$ at constant pressure, $q_p = \\Delta H$."
"#,
)
.unwrap();
Catalog::load(&dir).expect("catalog loads")
}
fn record() -> AssessmentFile {
use crate::assessment::{Assessment, Kind, Placement, Platform};
AssessmentFile {
schema_version: "1.0".into(),
assessment: Assessment {
id: "a1.1".into(),
title: "Homework 1".into(),
term: None,
kind: Kind::Homework,
date: None,
platform: Platform::Canvas,
minutes_allowed: None,
attempts: None,
shuffle: None,
scoring_policy: None,
instructions: None,
notes: None,
},
blueprint: None,
forms: Vec::new(),
items: vec![
Placement {
number: 1,
item: "b::q-mcq".into(),
version: None,
fingerprint: None,
points: Some(1.0),
bonus: false,
key: vec!["A".into()],
level: None,
learning_objectives: Vec::new(),
credit_overrides: Default::default(),
dropped: false,
},
Placement {
number: 2,
item: "b::q-open".into(),
version: None,
fingerprint: None,
points: Some(2.0),
bonus: false,
key: Vec::new(),
level: None,
learning_objectives: Vec::new(),
credit_overrides: Default::default(),
dropped: false,
},
],
}
}
#[test]
fn an_open_response_item_exports_as_a_canvas_essay() {
let cat = catalog("essay");
let rec = record();
let pkg = build(&cat, &rec, &QtiOptions::default())
.expect("an essay item does not block the export");
// Both questions survive: the choice question and the open-response essay.
assert!(
pkg.quiz_xml.contains("multiple_choice_question"),
"the choice question should be present"
);
assert!(
pkg.quiz_xml.contains("essay_question"),
"the open-response item should export as a Canvas essay"
);
// The essay stem's math is rendered with Canvas delimiters, and the model
// answer rides along as feedback.
assert!(pkg.quiz_xml.contains("\\(q_p = \\Delta H\\)"));
assert!(pkg.quiz_xml.contains("H = U + PV"));
}
}