// SPDX-License-Identifier: Prosperity-3.0.0 // Copyright Scientific Computing Studio // Source: https://git.scient.ing/education/coursebank //! Exporting an assessment as a Canvas-importable QTI 1.2 package. //! //! QTI 1.2 is a fussy, half-abandoned standard, and Canvas reads a particular //! dialect of it. Two details are worth recording because they are easy to get //! wrong and produce silent misbehavior rather than an import error. //! //! First, question HTML lives inside `` as //! character data, which means it is escaped once on the way in and unescaped //! once by Canvas. So `→` is written as `&rarr;`. Skipping that step //! produces XML that parses and renders as literal `→` to students. //! //! Second, identifiers must be stable. Canvas keys re-imports and question banks //! off them, so a package regenerated after a typo fix should carry the same ids //! as the original. Every id here is derived by hashing the assessment id //! together with the item's global id, never from a clock or a counter. //! //! Option order comes from the form, so exporting form A and form B of the same //! assessment gives two packages that ask the same questions in different orders, //! and the answer keys are guaranteed to agree with what was printed. use crate::assessment::{AssessmentFile, Form, ScoringPolicy}; use crate::catalog::Catalog; use crate::error::{Error, Result}; use crate::hash::{hex, sha256}; use crate::item::Item; use crate::markup; use crate::select; use crate::taxonomy::Format; use crate::zipfile::ZipBuilder; /// The QTI 1.2 namespace. const QTI_NS: &str = "http://www.imsglobal.org/xsd/ims_qtiasiv1p2"; /// The schema location Canvas expects alongside it. const QTI_SCHEMA: &str = "http://www.imsglobal.org/xsd/ims_qtiasiv1p2 \ http://www.imsglobal.org/xsd/ims_qtiasiv1p2p1.xsd"; /// The XML Schema instance namespace. const XSI_NS: &str = "http://www.w3.org/2001/XMLSchema-instance"; /// The IMS content packaging namespace. const IMSCP_NS: &str = "http://www.imsglobal.org/xsd/imscp_v1p1"; /// The IMS metadata namespace. const IMSMD_NS: &str = "http://www.imsglobal.org/xsd/imsmd_v1p2"; /// The content packaging schema location. const IMSCP_SCHEMA: &str = "http://www.imsglobal.org/xsd/imscp_v1p1 imscp_v1p1.xsd \ http://www.imsglobal.org/xsd/imsmd_v1p2 imsmd_v1p2p2.xsd"; // --------------------------------------------------------------------------- // A very small XML tree // --------------------------------------------------------------------------- /// One XML element. #[derive(Debug, Clone)] struct Node { tag: String, attrs: Vec<(String, String)>, text: Option, children: Vec, } impl Node { /// Creates an element with no attributes, text, or children. fn new(tag: &str) -> Node { Node { tag: tag.to_string(), attrs: Vec::new(), text: None, children: Vec::new(), } } /// Adds an attribute, returning self for chaining. fn attr(mut self, k: &str, v: impl Into) -> Node { self.attrs.push((k.to_string(), v.into())); self } /// Sets the element text, returning self for chaining. fn text(mut self, t: impl Into) -> Node { self.text = Some(t.into()); self } /// Appends a child, returning self for chaining. fn child(mut self, c: Node) -> Node { self.children.push(c); self } /// Appends several children, returning self for chaining. fn children(mut self, cs: Vec) -> Node { self.children.extend(cs); self } /// Renders the element and its subtree. /// /// # Arguments /// /// * `depth` - the indentation level. /// /// # Returns /// /// Indented XML, newline-terminated. fn render(&self, depth: usize) -> String { let pad = " ".repeat(depth); let mut attrs = String::new(); for (k, v) in &self.attrs { attrs.push_str(&format!(" {k}=\"{}\"", escape_attr(v))); } if self.children.is_empty() { match &self.text { None => format!("{pad}<{}{attrs}/>\n", self.tag), Some(t) => format!( "{pad}<{}{attrs}>{}\n", self.tag, escape_text(t), self.tag ), } } else { let mut out = format!("{pad}<{}{attrs}>\n", self.tag); if let Some(t) = &self.text { out.push_str(&format!("{pad} {}\n", escape_text(t))); } for c in &self.children { out.push_str(&c.render(depth + 1)); } out.push_str(&format!("{pad}\n", self.tag)); out } } /// Renders a complete document with an XML declaration. fn document(&self) -> String { format!( "\n{}", self.render(0) ) } } /// Escapes text content. fn escape_text(s: &str) -> String { s.replace('&', "&") .replace('<', "<") .replace('>', ">") } /// Escapes an attribute value. fn escape_attr(s: &str) -> String { escape_text(s).replace('"', """) } // --------------------------------------------------------------------------- // Package construction // --------------------------------------------------------------------------- /// Options for a QTI export. #[derive(Debug, Clone)] pub struct QtiOptions { /// Which form's option order to use. pub form: Form, /// Whether to include per-option feedback. Turn it off for a practice quiz /// you intend to reuse as a graded one, since Canvas shows this feedback /// immediately. 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. pub attempts: i64, /// How repeated attempts are scored. pub scoring_policy: ScoringPolicy, } impl Default for QtiOptions { fn default() -> QtiOptions { QtiOptions { form: Form { id: "A".to_string(), seed: 0, shuffle_items: false, shuffle_options: false, }, include_feedback: true, shuffle_in_canvas: false, attempts: 1, scoring_policy: ScoringPolicy::KeepHighest, } } } /// A rendered QTI package, ready to write. #[derive(Debug, Clone)] pub struct Package { /// The name of the quiz XML file inside the archive. pub quiz_filename: String, /// The quiz XML. pub quiz_xml: String, /// The manifest XML. pub manifest_xml: String, } impl Package { /// Writes the package as a Canvas-importable zip. /// /// # Arguments /// /// * `path` - the destination `.zip` path. /// /// # Errors /// /// Returns [`Error::Io`] on a write failure. pub fn write_zip(&self, path: &std::path::Path) -> Result<()> { let mut z = ZipBuilder::new(); // Canvas only recognizes the archive as QTI when the manifest sits at the // root rather than inside a directory. z.add_text("imsmanifest.xml", &self.manifest_xml); z.add_text(&self.quiz_filename, &self.quiz_xml); z.write_to(path) } } /// Builds a QTI package for an assessment. /// /// # Arguments /// /// * `catalog` - the loaded course, for resolving items. /// * `record` - the assessment record. /// * `opts` - export options. /// /// # Returns /// /// The rendered package. /// /// # Errors /// /// Returns [`Error::Unresolved`] when a placement references a missing item, and /// [`Error::Invalid`] when an item cannot be represented in QTI, such as one with /// no keyed option. pub fn build(catalog: &Catalog, record: &AssessmentFile, opts: &QtiOptions) -> Result { let assessment_id = qti_id(&format!("{}/assessment", record.assessment.id)); let default_points = catalog.course.policy.points_per_item; let mut problems = Vec::new(); let mut items = Vec::new(); for placement in select::layout(record, &opts.form) { let entry = catalog.require(&placement.item)?; let item = &entry.item; if item.key_indices().is_empty() { problems.push(format!( "question {} ({}) has no keyed option, so Canvas cannot score it", placement.number, placement.item )); continue; } let points = placement .points .unwrap_or_else(|| item.points(default_points)); items.push(build_item( &record.assessment.id, &placement.item, item, points, opts, )); } if !problems.is_empty() { return Err(Error::Invalid(problems)); } let metadata = vec![ metadata_field("cc_maxattempts", &opts.attempts.to_string()), metadata_field("cc_quiz_scoring_policy", opts.scoring_policy.as_str()), metadata_field( "cc_shuffle_answers", if opts.shuffle_in_canvas { "true" } else { "false" }, ), ]; let title = if record.forms.len() > 1 { format!("{} (form {})", record.assessment.title, opts.form.id) } else { record.assessment.title.clone() }; let assessment = Node::new("assessment") .attr("ident", assessment_id.clone()) .attr("title", title.clone()) .child(Node::new("qtimetadata").children(metadata)) .child( Node::new("section") .attr("ident", "root_section") .children(items), ); let root = Node::new("questestinterop") .attr("xmlns", QTI_NS) .attr("xmlns:xsi", XSI_NS) .attr("xsi:schemaLocation", QTI_SCHEMA) .child(assessment); let quiz_filename = format!("{}.xml", slug_filename(&record.assessment.id)); let manifest = build_manifest( &quiz_filename, &assessment_id, &title, &record.assessment.id, ); Ok(Package { quiz_filename, quiz_xml: root.document(), manifest_xml: manifest.document(), }) } /// Builds one `` element. /// /// # Arguments /// /// * `assessment_id` - salts the generated ids. /// * `uid` - the item's global id. /// * `item` - the item. /// * `points` - points as administered. /// * `opts` - export options. /// /// # Returns /// /// The element. fn build_item(assessment_id: &str, uid: &str, item: &Item, points: f64, opts: &QtiOptions) -> Node { let order = select::option_order(&opts.form, uid, item.options.len()); let ordered: Vec<&crate::item::Choice> = order.iter().map(|i| &item.options[*i]).collect(); // Option identifiers are numeric, mirroring Canvas's own exports, and are // derived from the item id so they survive regeneration. let opt_ids: Vec = ordered .iter() .map(|o| short_id(&format!("{assessment_id}/{uid}/{}", o.id))) .collect(); let item_meta = Node::new("itemmetadata").child(Node::new("qtimetadata").children(vec![ metadata_field("question_type", item.format.qti_type()), metadata_field("points_possible", &format!("{points:.2}")), metadata_field("original_answer_ids", &opt_ids.join(",")), metadata_field("assessment_question_identifierref", &qti_id(uid)), ])); let cardinality = if item.format == Format::MultipleResponse { "Multiple" } else { "Single" }; let labels: Vec = ordered .iter() .zip(opt_ids.iter()) .map(|(o, id)| { Node::new("response_label") .attr("ident", id.clone()) .child(mattext(&markup::to_html(&o.text))) }) .collect(); let presentation = Node::new("presentation") .child(mattext(&format!( "
{}
", markup::to_html(&item.stem) ))) .child( Node::new("response_lid") .attr("ident", "response1") .attr("rcardinality", cardinality) .child(Node::new("render_choice").children(labels)), ); // --- response processing --- let mut resprocessing = Node::new("resprocessing").child( Node::new("outcomes").child( Node::new("decvar") .attr("maxvalue", "100") .attr("minvalue", "0") .attr("varname", "SCORE") .attr("vartype", "Decimal"), ), ); // A pass-through condition per option, so choosing anything triggers its // 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() { continue; } resprocessing = resprocessing.child( Node::new("respcondition") .attr("continue", "Yes") .child( Node::new("conditionvar").child( Node::new("varequal") .attr("respident", "response1") .text(id.clone()), ), ) .child( Node::new("displayfeedback") .attr("feedbacktype", "Response") .attr("linkrefid", format!("{id}_fb")), ), ); } } let correct: Vec<&String> = ordered .iter() .zip(opt_ids.iter()) .filter(|(o, _)| o.correct) .map(|(_, id)| id) .collect(); let incorrect: Vec<&String> = ordered .iter() .zip(opt_ids.iter()) .filter(|(o, _)| !o.correct) .map(|(_, id)| id) .collect(); let condition = if item.format == Format::MultipleResponse { // Full credit only for the exact set: every keyed option chosen and no // unkeyed one. Without the negations, checking every box scores 100. let mut and = Node::new("and"); for id in &correct { and = and.child( Node::new("varequal") .attr("respident", "response1") .text((*id).clone()), ); } for id in &incorrect { and = and.child( Node::new("not").child( Node::new("varequal") .attr("respident", "response1") .text((*id).clone()), ), ); } Node::new("conditionvar").child(and) } else { Node::new("conditionvar").child( Node::new("varequal") .attr("respident", "response1") .text(correct.first().map(|s| (*s).clone()).unwrap_or_default()), ) }; resprocessing = resprocessing.child( Node::new("respcondition") .attr("continue", "No") .child(condition) .child( Node::new("setvar") .attr("action", "Set") .attr("varname", "SCORE") .text("100"), ), ); let mut node = Node::new("item") .attr("ident", qti_id(&format!("{assessment_id}/{uid}"))) .attr("title", item.display_title()) .child(item_meta) .child(presentation) .child(resprocessing); if opts.include_feedback { for (o, id) in ordered.iter().zip(opt_ids.iter()) { if let Some(text) = o.student_text() { node = node.child( Node::new("itemfeedback") .attr("ident", format!("{id}_fb")) .child( Node::new("flow_mat") .child(mattext(&format!("
{}
", markup::to_html(text)))), ), ); } } } node } /// A `` pair. /// /// # Arguments /// /// * `html` - the HTML fragment, which is escaped on the way in. /// /// # Returns /// /// The element. fn mattext(html: &str) -> Node { Node::new("material").child( Node::new("mattext") .attr("texttype", "text/html") .text(html), ) } /// A `` pair. fn metadata_field(label: &str, entry: &str) -> Node { Node::new("qtimetadatafield") .child(Node::new("fieldlabel").text(label)) .child(Node::new("fieldentry").text(entry)) } /// Builds the IMS content package manifest. /// /// # Arguments /// /// * `quiz_filename` - the quiz XML file name. /// * `assessment_id` - the assessment identifier, reused as the resource id. /// * `title` - the human title. /// * `salt` - salts the manifest identifier. /// /// # Returns /// /// The manifest element. fn build_manifest(quiz_filename: &str, assessment_id: &str, title: &str, salt: &str) -> Node { let lom = Node::new("imsmd:lom").child( Node::new("imsmd:general").child( Node::new("imsmd:title").child( Node::new("imsmd:langstring") .attr("xml:lang", "en-US") .text(title), ), ), ); Node::new("manifest") .attr("identifier", format!("man{}", qti_id(salt))) .attr("xmlns", IMSCP_NS) .attr("xmlns:imsmd", IMSMD_NS) .attr("xmlns:xsi", XSI_NS) .attr("xsi:schemaLocation", IMSCP_SCHEMA) .child( Node::new("metadata") .child(Node::new("schema").text("IMS Content")) .child(Node::new("schemaversion").text("1.1.3")) .child(lom), ) .child( Node::new("organizations").attr("default", "root").child( Node::new("organization") .attr("identifier", "root") .attr("structure", "rooted"), ), ) .child( Node::new("resources").child( Node::new("resource") .attr("identifier", assessment_id) .attr("type", "imsqti_xmlv1p2") .attr("href", quiz_filename) .child(Node::new("file").attr("href", quiz_filename)), ), ) } /// A deterministic QTI identifier: `g` followed by 32 hex characters. /// /// # Arguments /// /// * `key` - the stable string to derive from. /// /// # Returns /// /// The identifier. fn qti_id(key: &str) -> String { let digest = hex(&sha256(key.as_bytes())); format!("g{}", &digest[..32]) } /// A deterministic short numeric identifier, as Canvas uses for answers. /// /// # Arguments /// /// * `key` - the stable string to derive from. /// /// # Returns /// /// A four-digit numeric string. fn short_id(key: &str) -> String { let d = sha256(key.as_bytes()); let n = u32::from_be_bytes([d[0], d[1], d[2], d[3]]); // 1000..9999 keeps the width fixed, which some Canvas importers prefer. format!("{}", 1000 + (n % 9000)) } /// Makes a file-name-safe slug. fn slug_filename(s: &str) -> String { let mut out = String::new(); for ch in s.chars() { if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { out.push(ch); } else { out.push('_'); } } if out.is_empty() { "quiz".to_string() } else { out } } #[cfg(test)] mod tests { use super::*; #[test] fn escapes_html_once_inside_mattext() { let n = mattext("

a → b

"); let xml = n.render(0); // The HTML is character data, so its markup is escaped. assert!(xml.contains("<p>a &rarr; b</p>"), "{xml}"); assert!(!xml.contains("

")); } #[test] fn attributes_are_escaped() { let xml = Node::new("item") .attr("title", "a \"quoted\" & ") .render(0); assert!(xml.contains(""quoted"")); assert!(xml.contains("&")); assert!(!xml.contains("")); } #[test] fn ids_are_deterministic_and_well_formed() { assert_eq!(qti_id("a"), qti_id("a")); assert_ne!(qti_id("a"), qti_id("b")); let id = qti_id("exam-4/assessment"); assert_eq!(id.len(), 33); assert!(id.starts_with('g')); assert!(id[1..].chars().all(|c| c.is_ascii_hexdigit())); let s = short_id("x"); assert_eq!(s.len(), 4); assert!(s.parse::().unwrap() >= 1000); } #[test] fn empty_element_renders_self_closing() { assert_eq!( Node::new("file").attr("href", "q.xml").render(0), "\n" ); } #[test] fn document_has_a_declaration() { let doc = Node::new("root").document(); assert!(doc.starts_with("\n")); } #[test] fn manifest_points_at_the_quiz_file() { let m = build_manifest("exam-4.xml", "gabc", "Exam 4", "exam-4").render(0); assert!(m.contains("imsqti_xmlv1p2")); assert!(m.contains("href=\"exam-4.xml\"")); assert!(m.contains("Exam 4")); } #[test] fn slug_filename_is_safe() { assert_eq!(slug_filename("exam-4 2026s"), "exam-4_2026s"); assert_eq!(slug_filename(""), "quiz"); } }