fix: handling of equations in QTI
This commit is contained in:
+9
-3
@@ -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,
|
||||
|
||||
@@ -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
@@ -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<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 `<` 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 < b\\)</p>");
|
||||
// Prose with no math is escaped and wrapped, same as `to_html`.
|
||||
assert_eq!(to_html_canvas("a & b"), "<p>a & 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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user