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.
|
/// Leave per-option feedback out of the package.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
no_feedback: bool,
|
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.
|
/// Render a printable exam, answer key, and answer sheet.
|
||||||
///
|
///
|
||||||
@@ -436,10 +441,11 @@ pub(crate) enum ExportCommand {
|
|||||||
no_answer_space: bool,
|
no_answer_space: bool,
|
||||||
},
|
},
|
||||||
/// Write a Quarto questions partial and an encrypted, password-gated
|
/// 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,
|
/// Needs the `site` feature (`cargo build --features site`). Writes
|
||||||
/// and prints a fresh password that the files do not store.
|
/// `_questions.qmd` and `<id>-solutions.json` into the output directory, and
|
||||||
|
/// prints a fresh password that the files do not store.
|
||||||
Site {
|
Site {
|
||||||
/// Assessment id.
|
/// Assessment id.
|
||||||
id: String,
|
id: String,
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ pub(crate) fn export(cli: &Cli, sub: &ExportCommand) -> Result<Outcome> {
|
|||||||
form,
|
form,
|
||||||
out,
|
out,
|
||||||
no_feedback,
|
no_feedback,
|
||||||
|
attempts: _,
|
||||||
} => {
|
} => {
|
||||||
let record = load_record(&catalog, id)?;
|
let record = load_record(&catalog, id)?;
|
||||||
let form = pick_form(&record, form)?;
|
let form = pick_form(&record, form)?;
|
||||||
|
|||||||
+299
-9
@@ -153,6 +153,101 @@ fn escape_attr(s: &str) -> String {
|
|||||||
escape_text(s).replace('"', """)
|
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
|
// Package construction
|
||||||
|
|
||||||
/// Options for a QTI export.
|
/// Options for a QTI export.
|
||||||
@@ -166,7 +261,9 @@ pub struct QtiOptions {
|
|||||||
pub include_feedback: bool,
|
pub include_feedback: bool,
|
||||||
/// Whether to let Canvas shuffle answers on top of the form's own order.
|
/// Whether to let Canvas shuffle answers on top of the form's own order.
|
||||||
pub shuffle_in_canvas: bool,
|
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,
|
pub attempts: i64,
|
||||||
/// How repeated attempts are scored.
|
/// How repeated attempts are scored.
|
||||||
pub scoring_policy: ScoringPolicy,
|
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) {
|
for placement in select::layout(record, &opts.form) {
|
||||||
let entry = catalog.require(&placement.item)?;
|
let entry = catalog.require(&placement.item)?;
|
||||||
let item = &entry.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!(
|
problems.push(format!(
|
||||||
"question {} ({}) has no keyed option, so Canvas cannot score it",
|
"question {} ({}) has no keyed option, so Canvas cannot score it",
|
||||||
placement.number, placement.item
|
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)| {
|
.map(|(o, id)| {
|
||||||
Node::new("response_label")
|
Node::new("response_label")
|
||||||
.attr("ident", id.clone())
|
.attr("ident", id.clone())
|
||||||
.child(mattext(&markup::to_html(&o.text)))
|
.child(mattext(&to_html_canvas(&o.text)))
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let presentation = Node::new("presentation")
|
let presentation = Node::new("presentation")
|
||||||
.child(mattext(&format!(
|
.child(mattext(&format!(
|
||||||
"<div>{}</div>",
|
"<div>{}</div>",
|
||||||
markup::to_html(&item.stem)
|
to_html_canvas(&item.stem)
|
||||||
)))
|
)))
|
||||||
.child(
|
.child(
|
||||||
Node::new("response_lid")
|
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.
|
// feedback. `continue="Yes"` is what allows scoring to be evaluated after.
|
||||||
if opts.include_feedback {
|
if opts.include_feedback {
|
||||||
for (o, id) in ordered.iter().zip(opt_ids.iter()) {
|
for (o, id) in ordered.iter().zip(opt_ids.iter()) {
|
||||||
if o.student_text().is_none() {
|
if option_feedback(o, opts).is_none() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
resprocessing = resprocessing.child(
|
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 {
|
if opts.include_feedback {
|
||||||
for (o, id) in ordered.iter().zip(opt_ids.iter()) {
|
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 = node.child(
|
||||||
Node::new("itemfeedback")
|
Node::new("itemfeedback")
|
||||||
.attr("ident", format!("{id}_fb"))
|
.attr("ident", format!("{id}_fb"))
|
||||||
.child(
|
.child(
|
||||||
Node::new("flow_mat")
|
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")
|
let presentation = Node::new("presentation")
|
||||||
.child(mattext(&format!(
|
.child(mattext(&format!(
|
||||||
"<div>{}</div>",
|
"<div>{}</div>",
|
||||||
markup::to_html(&item.stem)
|
to_html_canvas(&item.stem)
|
||||||
)))
|
)))
|
||||||
.child(
|
.child(
|
||||||
Node::new("response_str")
|
Node::new("response_str")
|
||||||
@@ -591,7 +691,7 @@ fn build_essay_item(
|
|||||||
|
|
||||||
if let Some(text) = model {
|
if let Some(text) = model {
|
||||||
node = node.child(Node::new("itemfeedback").attr("ident", "general_fb").child(
|
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("exam-4 2026s"), "exam-4_2026s");
|
||||||
assert_eq!(slug_filename(""), "quiz");
|
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