From 4cd33f17686c7fcedfa523be6305e4ffb6ed43d7 Mon Sep 17 00:00:00 2001 From: Alex Maldonado Date: Mon, 17 Aug 2026 01:37:01 -0400 Subject: [PATCH] fix: handling of equations in QTI --- src/cli.rs | 12 +- src/commands/export.rs | 1 + src/export/qti.rs | 308 +++++++++++++++++++++++++++++++++++++++-- 3 files changed, 309 insertions(+), 12 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 959779e..452a171 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -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, }, /// 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 `-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 `-solutions.json` into the output directory, and + /// prints a fresh password that the files do not store. Site { /// Assessment id. id: String, diff --git a/src/commands/export.rs b/src/commands/export.rs index 41fd546..c42cdfc 100644 --- a/src/commands/export.rs +++ b/src/commands/export.rs @@ -32,6 +32,7 @@ pub(crate) fn export(cli: &Cli, sub: &ExportCommand) -> Result { form, out, no_feedback, + attempts: _, } => { let record = load_record(&catalog, id)?; let form = pick_form(&record, form)?; diff --git a/src/export/qti.rs b/src/export/qti.rs index c5e6251..c8583f1 100644 --- a/src/export/qti.rs +++ b/src/export/qti.rs @@ -153,6 +153,101 @@ fn escape_attr(s: &str) -> String { escape_text(s).replace('"', """) } +/// 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 = 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::>() + .join(" "); + format!("

{}

", 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 `<` 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!( "
{}
", - 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!("
{}
", markup::to_html(text)))), + .child(mattext(&format!("
{}
", to_html_canvas(text)))), ), ); } @@ -537,7 +637,7 @@ fn build_essay_item( let presentation = Node::new("presentation") .child(mattext(&format!( "
{}
", - 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!("
{}
", markup::to_html(text)))), + Node::new("flow_mat").child(mattext(&format!("
{}
", 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$"), + "

Enthalpy, \\(\\Delta H\\)

" + ); + // Display $$...$$ is left as a Canvas block. + assert_eq!(to_html_canvas("$$E = mc^2$$"), "

$$E = mc^2$$

"); + // A subscript inside math is not read as emphasis. + assert_eq!( + to_html_canvas("$q_p = \\Delta H$"), + "

\\(q_p = \\Delta H\\)

" + ); + // A `<` inside math is escaped for transport; Canvas decodes it before + // MathJax reads it. + assert_eq!(to_html_canvas("$a < b$"), "

\\(a < b\\)

"); + // Prose with no math is escaped and wrapped, same as `to_html`. + assert_eq!(to_html_canvas("a & b"), "

a & b

"); + } + + 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")); + } }