// lib/canvas/qti.js — render the neutral Canvas IR (map.js) into a Canvas QTI 1.2
// assessment XML document. Structure mirrors Canvas's own QTI export / parser fixtures
// (instructure/qti) so the package imports cleanly into Classic Quizzes (and, via the
// "import as New Quizzes" checkbox, New Quizzes too).
const xmlEsc = (s) =>
String(s ?? "")
.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """);
const matHtml = (html) =>
`${xmlEsc(html)}`;
const matPlain = (text) =>
`${xmlEsc(text)}`;
const OUTCOMES =
``;
const metaField = (label, entry) =>
`${label}${xmlEsc(entry)}`;
// Even split of 100 points across n blanks/pairs, last absorbs the rounding remainder.
function addValues(n) {
const base = Math.floor((10000 / n)) / 100;
const vals = Array(n).fill(base);
vals[n - 1] = Math.round((100 - base * (n - 1)) * 100) / 100;
return vals.map((v) => v.toFixed(2));
}
function generalFeedback(it) {
const html = it.feedback?.neutralHtml;
if (!html) return { block: "", cond: "" };
return {
block: `${matHtml(html)}`,
cond: `` +
``,
};
}
function wrapItem(it, presentationInner, conditions, extraMeta = "") {
const fb = generalFeedback(it);
const meta =
metaField("question_type", it.canvasType) +
metaField("points_possible", String(it.points ?? 0)) +
extraMeta;
return `-
${meta}
${matHtml(it.promptHtml || "")}${presentationInner}
${OUTCOMES}${conditions}${fb.cond}${fb.block}
`;
}
function renderChoice(it, cardinality) {
const labels = it.choices
.map((c) => `${matHtml(c.html)}`)
.join("");
const present =
`${labels}`;
const correct = it.choices.filter((c) => c.correct);
const pick = (correct[0] || it.choices[0]);
const cond =
`${pick.ident}` +
`100`;
const extraMeta = metaField("original_answer_ids", it.choices.map((c) => c.ident).join(","));
return wrapItem(it, present, cond, extraMeta);
}
function renderEssay(it) {
const present =
``;
const cond = ``;
return wrapItem(it, present, cond);
}
function renderFillBlanks(it) {
const present = it.blanks.map((b) => {
const labels = b.answers
.map((a) => `${matPlain(a.textPlain)}`)
.join("");
return `${matPlain(b.name)}${labels}`;
}).join("");
const add = addValues(it.blanks.length);
const conds = it.blanks.map((b, i) =>
`${b.answers[0].ident}` +
`${add[i]}`
).join("");
return wrapItem(it, present, conds);
}
function renderMatching(it) {
const rights = it.rights
.map((r) => `${matPlain(r.textPlain)}`)
.join("");
const present = it.lefts.map((l) =>
`${matPlain(l.textPlain)}` +
`${rights}`
).join("");
const add = addValues(it.lefts.length);
const conds = it.lefts.map((l, i) =>
`${l.correctRightIdent}` +
`${add[i]}`
).join("");
return wrapItem(it, present, conds);
}
function renderTextOnly(it) {
const meta = metaField("question_type", "text_only_question") + metaField("points_possible", "0");
return `-
${meta}
${matHtml(it.promptHtml || "")}
${OUTCOMES}
`;
}
function renderItem(it) {
switch (it.canvasType) {
case "multiple_choice_question": return renderChoice(it, "Single");
case "true_false_question": return renderChoice(it, "Single");
case "essay_question": return renderEssay(it);
case "fill_in_multiple_blanks_question": return renderFillBlanks(it);
case "matching_question": return renderMatching(it);
case "text_only_question": return renderTextOnly(it);
default: return "";
}
}
export function renderAssessmentXml(ir, assessmentIdent) {
const items = ir.items.map(renderItem).filter(Boolean).join("\n");
return `
cc_maxattempts1
`;
}
export { xmlEsc };