@longsightgroup/qti3-core 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/catalog.d.ts +32 -0
- package/dist/catalog.d.ts.map +1 -0
- package/dist/catalog.js +126 -0
- package/dist/catalog.js.map +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +293 -6
- package/dist/parser.js.map +1 -1
- package/dist/session.d.ts +3 -1
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +68 -3
- package/dist/session.js.map +1 -1
- package/dist/support.js +7 -1
- package/dist/support.js.map +1 -1
- package/dist/tts.d.ts +61 -0
- package/dist/tts.d.ts.map +1 -0
- package/dist/tts.js +368 -0
- package/dist/tts.js.map +1 -0
- package/dist/types.d.ts +61 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/validation.d.ts.map +1 -1
- package/dist/validation.js +243 -11
- package/dist/validation.js.map +1 -1
- package/package.json +4 -2
- package/src/catalog.ts +193 -0
- package/src/index.ts +85 -0
- package/src/parser.ts +1859 -0
- package/src/session.ts +2284 -0
- package/src/support.ts +253 -0
- package/src/tts.ts +555 -0
- package/src/types.ts +703 -0
- package/src/validation.ts +2449 -0
- package/src/xml.ts +139 -0
package/src/parser.ts
ADDED
|
@@ -0,0 +1,1859 @@
|
|
|
1
|
+
import { getInteractionSupport, interactionNameToType, processingSupport } from "./support.js";
|
|
2
|
+
import type {
|
|
3
|
+
QtiAssessmentItem,
|
|
4
|
+
QtiBaseType,
|
|
5
|
+
QtiCatalogCard,
|
|
6
|
+
QtiCatalogCardEntry,
|
|
7
|
+
QtiCatalogFileHref,
|
|
8
|
+
QtiCatalogHtmlContent,
|
|
9
|
+
QtiCatalogInfo,
|
|
10
|
+
QtiCatalogReference,
|
|
11
|
+
QtiCardinality,
|
|
12
|
+
QtiChoice,
|
|
13
|
+
QtiChoiceRole,
|
|
14
|
+
QtiContentNode,
|
|
15
|
+
QtiDiagnostic,
|
|
16
|
+
QtiDocument,
|
|
17
|
+
QtiInteraction,
|
|
18
|
+
QtiInteractionType,
|
|
19
|
+
QtiLookupOutcomeValue,
|
|
20
|
+
QtiLookupTable,
|
|
21
|
+
QtiMediaSource,
|
|
22
|
+
QtiModalFeedback,
|
|
23
|
+
QtiObjectAsset,
|
|
24
|
+
QtiOutcomeDeclaration,
|
|
25
|
+
QtiParseResult,
|
|
26
|
+
QtiProcessingExpression,
|
|
27
|
+
QtiPortableCustomDefinition,
|
|
28
|
+
QtiPortableCustomInteractionModule,
|
|
29
|
+
QtiPortableCustomInteractionModules,
|
|
30
|
+
QtiPortableCustomVariableBinding,
|
|
31
|
+
QtiRecordValue,
|
|
32
|
+
QtiResponseCondition,
|
|
33
|
+
QtiResponseDeclaration,
|
|
34
|
+
QtiResponseProcessing,
|
|
35
|
+
QtiResponseRule,
|
|
36
|
+
QtiSetOutcomeValue,
|
|
37
|
+
QtiScalarValue,
|
|
38
|
+
QtiStylesheet,
|
|
39
|
+
QtiTemplateDeclaration,
|
|
40
|
+
QtiTemplateProcessing,
|
|
41
|
+
QtiTemplateRule,
|
|
42
|
+
QtiValue,
|
|
43
|
+
} from "./types.js";
|
|
44
|
+
import { validateAssessmentItem } from "./validation.js";
|
|
45
|
+
import { childElements, descendants, parseXmlTree, textContent, type XmlNode } from "./xml.js";
|
|
46
|
+
|
|
47
|
+
const supportedProcessingNames = new Set(processingSupport.map((entry) => entry.qtiName));
|
|
48
|
+
const processingContainerNames = new Set(["qti-template-processing", "qti-response-processing"]);
|
|
49
|
+
const responseProcessingForbiddenNames = new Set([
|
|
50
|
+
"qti-number-correct",
|
|
51
|
+
"qti-number-incorrect",
|
|
52
|
+
"qti-number-presented",
|
|
53
|
+
"qti-number-responded",
|
|
54
|
+
"qti-number-selected",
|
|
55
|
+
"qti-outcome-minimum",
|
|
56
|
+
"qti-outcome-maximum",
|
|
57
|
+
"qti-test-variables",
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
export function parseQtiXml(xml: string): QtiParseResult {
|
|
61
|
+
const diagnostics: QtiDiagnostic[] = [];
|
|
62
|
+
const tree = parseXmlTree(xml);
|
|
63
|
+
|
|
64
|
+
for (const error of tree.errors) {
|
|
65
|
+
diagnostics.push({
|
|
66
|
+
code: "xml.parse",
|
|
67
|
+
severity: "error",
|
|
68
|
+
message: error.message,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!tree.root) {
|
|
73
|
+
diagnostics.push({
|
|
74
|
+
code: "xml.empty",
|
|
75
|
+
severity: "error",
|
|
76
|
+
message: "No XML root element was found.",
|
|
77
|
+
});
|
|
78
|
+
return { ok: false, diagnostics };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const itemNode = tree.root.localName === "qti-assessment-item" ? tree.root : undefined;
|
|
82
|
+
if (!itemNode) {
|
|
83
|
+
diagnostics.push({
|
|
84
|
+
code: "qti.root",
|
|
85
|
+
severity: "error",
|
|
86
|
+
message: `Expected qti-assessment-item root, found ${tree.root.localName}.`,
|
|
87
|
+
path: tree.root.source.path,
|
|
88
|
+
source: tree.root.source,
|
|
89
|
+
});
|
|
90
|
+
return { ok: false, diagnostics };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const item = parseAssessmentItem(itemNode, diagnostics);
|
|
94
|
+
const document: QtiDocument = { item, diagnostics };
|
|
95
|
+
const validation = validateAssessmentItem(document);
|
|
96
|
+
diagnostics.push(...validation.diagnostics);
|
|
97
|
+
return {
|
|
98
|
+
ok: diagnostics.every((diagnostic) => diagnostic.severity !== "error"),
|
|
99
|
+
document,
|
|
100
|
+
diagnostics,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function parseAssessmentItem(node: XmlNode, diagnostics: QtiDiagnostic[]): QtiAssessmentItem {
|
|
105
|
+
const identifier = node.attributes.identifier ?? "";
|
|
106
|
+
const responseDeclarations = childElements(node, "qti-response-declaration").map(
|
|
107
|
+
parseResponseDeclaration,
|
|
108
|
+
);
|
|
109
|
+
const responseDeclarationMap = new Map(
|
|
110
|
+
responseDeclarations.map((declaration) => [declaration.identifier, declaration]),
|
|
111
|
+
);
|
|
112
|
+
const outcomeDeclarations = childElements(node, "qti-outcome-declaration").map(
|
|
113
|
+
parseOutcomeDeclaration,
|
|
114
|
+
);
|
|
115
|
+
const templateDeclarations = childElements(node, "qti-template-declaration").map(
|
|
116
|
+
parseTemplateDeclaration,
|
|
117
|
+
);
|
|
118
|
+
const templateProcessing = parseTemplateProcessing(
|
|
119
|
+
childElements(node, "qti-template-processing")[0],
|
|
120
|
+
);
|
|
121
|
+
const responseProcessing = parseResponseProcessing(
|
|
122
|
+
childElements(node, "qti-response-processing")[0],
|
|
123
|
+
);
|
|
124
|
+
diagnoseProcessingElements(childElements(node, "qti-template-processing")[0], diagnostics);
|
|
125
|
+
diagnoseProcessingElements(childElements(node, "qti-response-processing")[0], diagnostics);
|
|
126
|
+
const itemBody = childElements(node, "qti-item-body")[0];
|
|
127
|
+
const interactions: QtiInteraction[] = [];
|
|
128
|
+
const body = itemBody
|
|
129
|
+
? parseContentChildren(itemBody, diagnostics, responseDeclarationMap, interactions)
|
|
130
|
+
: [];
|
|
131
|
+
if (!itemBody) {
|
|
132
|
+
interactions.push(
|
|
133
|
+
...descendants(node, isInteractionElement).map((interactionNode) =>
|
|
134
|
+
parseInteraction(interactionNode, diagnostics, responseDeclarationMap),
|
|
135
|
+
),
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
const modalFeedback = childElements(node, "qti-modal-feedback").map(parseModalFeedback);
|
|
139
|
+
const catalogInfo = parseCatalogInfo(childElements(node, "qti-catalog-info")[0]);
|
|
140
|
+
const catalogReferences = itemBody ? parseCatalogReferences(itemBody) : [];
|
|
141
|
+
const stylesheets = childElements(node, "qti-stylesheet").map(parseStylesheet);
|
|
142
|
+
const prompt = itemBody ? childElements(itemBody, "qti-prompt")[0] : undefined;
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
identifier,
|
|
146
|
+
title: node.attributes.title,
|
|
147
|
+
language: node.attributes["xml:lang"] ?? node.attributes.lang,
|
|
148
|
+
adaptive: node.attributes.adaptive === "true",
|
|
149
|
+
prompt: prompt ? textContent(prompt) : undefined,
|
|
150
|
+
responseDeclarations,
|
|
151
|
+
outcomeDeclarations,
|
|
152
|
+
templateDeclarations,
|
|
153
|
+
templateProcessing,
|
|
154
|
+
responseProcessing,
|
|
155
|
+
interactions,
|
|
156
|
+
modalFeedback,
|
|
157
|
+
catalogInfo,
|
|
158
|
+
catalogReferences,
|
|
159
|
+
stylesheets,
|
|
160
|
+
body,
|
|
161
|
+
bodyText: textContent(node),
|
|
162
|
+
source: node.source,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function diagnoseProcessingElements(
|
|
167
|
+
processingNode: XmlNode | undefined,
|
|
168
|
+
diagnostics: QtiDiagnostic[],
|
|
169
|
+
): void {
|
|
170
|
+
if (!processingNode) return;
|
|
171
|
+
for (const node of [processingNode, ...descendants(processingNode, () => true)]) {
|
|
172
|
+
if (!node.localName.startsWith("qti-")) continue;
|
|
173
|
+
if (
|
|
174
|
+
processingNode.localName === "qti-response-processing" &&
|
|
175
|
+
responseProcessingForbiddenNames.has(node.localName)
|
|
176
|
+
) {
|
|
177
|
+
diagnostics.push({
|
|
178
|
+
code: "processing.response.forbidden",
|
|
179
|
+
severity: "error",
|
|
180
|
+
message: `${node.localName} must not be used in qti-response-processing.`,
|
|
181
|
+
path: node.source.path,
|
|
182
|
+
source: node.source,
|
|
183
|
+
});
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (
|
|
187
|
+
processingContainerNames.has(node.localName) ||
|
|
188
|
+
supportedProcessingNames.has(node.localName)
|
|
189
|
+
) {
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
diagnostics.push({
|
|
193
|
+
code: "processing.unsupported",
|
|
194
|
+
severity: "error",
|
|
195
|
+
message: `${node.localName} is not currently supported as a QTI processing element.`,
|
|
196
|
+
path: node.source.path,
|
|
197
|
+
source: node.source,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function parseStylesheet(node: XmlNode): QtiStylesheet {
|
|
203
|
+
return {
|
|
204
|
+
href: node.attributes.href ?? "",
|
|
205
|
+
type: node.attributes.type,
|
|
206
|
+
media: node.attributes.media,
|
|
207
|
+
title: node.attributes.title,
|
|
208
|
+
attributes: node.attributes,
|
|
209
|
+
source: node.source,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function parseCatalogReferences(node: XmlNode): QtiCatalogReference[] {
|
|
214
|
+
const references = [
|
|
215
|
+
...(node.attributes["data-catalog-idref"] ? [node] : []),
|
|
216
|
+
...descendants(node, (child) => Boolean(child.attributes["data-catalog-idref"])),
|
|
217
|
+
];
|
|
218
|
+
return references.map((reference) => ({
|
|
219
|
+
idref: reference.attributes["data-catalog-idref"] ?? "",
|
|
220
|
+
source: reference.source,
|
|
221
|
+
}));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function isInteractionElement(node: XmlNode): boolean {
|
|
225
|
+
return interactionNameToType.has(node.localName) || /^qti-.+-interaction$/.test(node.localName);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function parseModalFeedback(node: XmlNode): QtiModalFeedback {
|
|
229
|
+
const showHide = node.attributes["show-hide"] === "hide" ? "hide" : "show";
|
|
230
|
+
return {
|
|
231
|
+
identifier: node.attributes.identifier ?? "",
|
|
232
|
+
outcomeIdentifier: node.attributes["outcome-identifier"] ?? "",
|
|
233
|
+
showHide,
|
|
234
|
+
text: textContent(node),
|
|
235
|
+
source: node.source,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function parseContentChildren(
|
|
240
|
+
node: XmlNode,
|
|
241
|
+
diagnostics: QtiDiagnostic[],
|
|
242
|
+
responseDeclarationMap: Map<string, QtiResponseDeclaration>,
|
|
243
|
+
interactions: QtiInteraction[],
|
|
244
|
+
): QtiContentNode[] {
|
|
245
|
+
const content: QtiContentNode[] = [];
|
|
246
|
+
for (const entry of node.content) {
|
|
247
|
+
if (typeof entry === "string") {
|
|
248
|
+
if (entry.length > 0) content.push({ kind: "text", text: entry, source: node.source });
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
const parsed = parseContentNode(entry, diagnostics, responseDeclarationMap, interactions);
|
|
252
|
+
if (parsed) content.push(parsed);
|
|
253
|
+
}
|
|
254
|
+
return content;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function parseContentNode(
|
|
258
|
+
node: XmlNode,
|
|
259
|
+
diagnostics: QtiDiagnostic[],
|
|
260
|
+
responseDeclarationMap: Map<string, QtiResponseDeclaration>,
|
|
261
|
+
interactions: QtiInteraction[],
|
|
262
|
+
): QtiContentNode | undefined {
|
|
263
|
+
if (isInteractionElement(node)) {
|
|
264
|
+
const interaction = parseInteraction(node, diagnostics, responseDeclarationMap);
|
|
265
|
+
const interactionIndex = interactions.push(interaction) - 1;
|
|
266
|
+
return {
|
|
267
|
+
kind: "interaction",
|
|
268
|
+
interactionIndex,
|
|
269
|
+
qtiName: node.localName,
|
|
270
|
+
responseIdentifier: interaction.responseIdentifier,
|
|
271
|
+
source: node.source,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (node.localName === "qti-printed-variable") {
|
|
276
|
+
return {
|
|
277
|
+
kind: "printedVariable",
|
|
278
|
+
identifier: node.attributes.identifier ?? "",
|
|
279
|
+
format: node.attributes.format,
|
|
280
|
+
attributes: node.attributes,
|
|
281
|
+
source: node.source,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (node.localName === "qti-feedback-block" || node.localName === "qti-feedback-inline") {
|
|
286
|
+
return {
|
|
287
|
+
kind: "feedback",
|
|
288
|
+
feedbackType: node.localName === "qti-feedback-block" ? "block" : "inline",
|
|
289
|
+
identifier: node.attributes.identifier ?? "",
|
|
290
|
+
outcomeIdentifier: node.attributes["outcome-identifier"] ?? "",
|
|
291
|
+
showHide: node.attributes["show-hide"] === "hide" ? "hide" : "show",
|
|
292
|
+
attributes: node.attributes,
|
|
293
|
+
children: parseContentChildren(node, diagnostics, responseDeclarationMap, interactions),
|
|
294
|
+
source: node.source,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
kind: "element",
|
|
300
|
+
qtiName: node.localName,
|
|
301
|
+
attributes: node.attributes,
|
|
302
|
+
children: parseContentChildren(node, diagnostics, responseDeclarationMap, interactions),
|
|
303
|
+
source: node.source,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function parseCatalogInfo(node: XmlNode | undefined): QtiCatalogInfo | undefined {
|
|
308
|
+
if (!node) return undefined;
|
|
309
|
+
return {
|
|
310
|
+
catalogs: childElements(node, "qti-catalog").map((catalog) => ({
|
|
311
|
+
id: catalog.attributes.id ?? "",
|
|
312
|
+
attributes: catalog.attributes,
|
|
313
|
+
cards: childElements(catalog, "qti-card").map(parseCatalogCard),
|
|
314
|
+
source: catalog.source,
|
|
315
|
+
})),
|
|
316
|
+
source: node.source,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function parseCatalogCard(node: XmlNode): QtiCatalogCard {
|
|
321
|
+
return {
|
|
322
|
+
support: node.attributes.support ?? "",
|
|
323
|
+
htmlContent: parseCatalogHtmlContent(childElements(node, "qti-html-content")[0]),
|
|
324
|
+
fileHrefs: childElements(node, "qti-file-href").map(parseCatalogFileHref),
|
|
325
|
+
entries: childElements(node, "qti-card-entry").map(parseCatalogCardEntry),
|
|
326
|
+
attributes: node.attributes,
|
|
327
|
+
source: node.source,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function parseCatalogCardEntry(node: XmlNode): QtiCatalogCardEntry {
|
|
332
|
+
return {
|
|
333
|
+
language: node.attributes["xml:lang"] ?? node.attributes.lang,
|
|
334
|
+
default: node.attributes.default === "true",
|
|
335
|
+
htmlContent: parseCatalogHtmlContent(childElements(node, "qti-html-content")[0]),
|
|
336
|
+
fileHrefs: childElements(node, "qti-file-href").map(parseCatalogFileHref),
|
|
337
|
+
attributes: node.attributes,
|
|
338
|
+
source: node.source,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function parseCatalogHtmlContent(node: XmlNode | undefined): QtiCatalogHtmlContent | undefined {
|
|
343
|
+
if (!node) return undefined;
|
|
344
|
+
return {
|
|
345
|
+
text: textContent(node),
|
|
346
|
+
children: parseCatalogHtmlChildren(node),
|
|
347
|
+
attributes: node.attributes,
|
|
348
|
+
source: node.source,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function parseCatalogHtmlChildren(node: XmlNode): QtiContentNode[] {
|
|
353
|
+
const content: QtiContentNode[] = [];
|
|
354
|
+
for (const entry of node.content) {
|
|
355
|
+
if (typeof entry === "string") {
|
|
356
|
+
if (entry.length > 0) content.push({ kind: "text", text: entry, source: node.source });
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
content.push({
|
|
360
|
+
kind: "element",
|
|
361
|
+
qtiName: entry.localName,
|
|
362
|
+
attributes: entry.attributes,
|
|
363
|
+
children: parseCatalogHtmlChildren(entry),
|
|
364
|
+
source: entry.source,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
return content;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function parseCatalogFileHref(node: XmlNode): QtiCatalogFileHref {
|
|
371
|
+
return {
|
|
372
|
+
href: textContent(node).trim(),
|
|
373
|
+
mimeType: node.attributes["mime-type"],
|
|
374
|
+
attributes: node.attributes,
|
|
375
|
+
source: node.source,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function parseResponseDeclaration(node: XmlNode): QtiResponseDeclaration {
|
|
380
|
+
const cardinality = parseCardinality(node.attributes.cardinality);
|
|
381
|
+
const baseType = node.attributes["base-type"] as QtiResponseDeclaration["baseType"];
|
|
382
|
+
return {
|
|
383
|
+
kind: "response",
|
|
384
|
+
identifier: node.attributes.identifier ?? "",
|
|
385
|
+
cardinality,
|
|
386
|
+
baseType,
|
|
387
|
+
defaultValue: parseVariableValue(childElements(node, "qti-default-value")[0], baseType),
|
|
388
|
+
correctResponse: normalizeValueForCardinality(
|
|
389
|
+
parseVariableValue(childElements(node, "qti-correct-response")[0], baseType),
|
|
390
|
+
cardinality,
|
|
391
|
+
),
|
|
392
|
+
mapping: parseMapping(childElements(node, "qti-mapping")[0]),
|
|
393
|
+
areaMapping: parseAreaMapping(childElements(node, "qti-area-mapping")[0]),
|
|
394
|
+
attributes: node.attributes,
|
|
395
|
+
source: node.source,
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function parseOutcomeDeclaration(node: XmlNode): QtiOutcomeDeclaration {
|
|
400
|
+
const baseType = node.attributes["base-type"] as QtiOutcomeDeclaration["baseType"];
|
|
401
|
+
return {
|
|
402
|
+
kind: "outcome",
|
|
403
|
+
identifier: node.attributes.identifier ?? "",
|
|
404
|
+
cardinality: parseCardinality(node.attributes.cardinality),
|
|
405
|
+
baseType,
|
|
406
|
+
defaultValue: parseVariableValue(childElements(node, "qti-default-value")[0], baseType),
|
|
407
|
+
lookupTable: parseLookupTable(node, baseType),
|
|
408
|
+
attributes: node.attributes,
|
|
409
|
+
source: node.source,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function parseTemplateDeclaration(node: XmlNode): QtiTemplateDeclaration {
|
|
414
|
+
const baseType = node.attributes["base-type"] as QtiTemplateDeclaration["baseType"];
|
|
415
|
+
return {
|
|
416
|
+
kind: "template",
|
|
417
|
+
identifier: node.attributes.identifier ?? "",
|
|
418
|
+
cardinality: parseCardinality(node.attributes.cardinality),
|
|
419
|
+
baseType,
|
|
420
|
+
defaultValue: parseVariableValue(childElements(node, "qti-default-value")[0], baseType),
|
|
421
|
+
attributes: node.attributes,
|
|
422
|
+
source: node.source,
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function parseInteraction(
|
|
427
|
+
node: XmlNode,
|
|
428
|
+
diagnostics: QtiDiagnostic[],
|
|
429
|
+
responseDeclarationMap: Map<string, QtiResponseDeclaration>,
|
|
430
|
+
): QtiInteraction {
|
|
431
|
+
const interactionType = interactionNameToType.get(node.localName);
|
|
432
|
+
const responseIdentifier = node.attributes["response-identifier"];
|
|
433
|
+
const responseDeclaration = responseIdentifier
|
|
434
|
+
? responseDeclarationMap.get(responseIdentifier)
|
|
435
|
+
: undefined;
|
|
436
|
+
const prompt = childElements(node, "qti-prompt")[0];
|
|
437
|
+
if (!interactionType) {
|
|
438
|
+
diagnostics.push({
|
|
439
|
+
code: "interaction.unsupported",
|
|
440
|
+
severity: "warning",
|
|
441
|
+
message: `${node.localName} is not currently in the support registry.`,
|
|
442
|
+
path: node.source.path,
|
|
443
|
+
source: node.source,
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
const support = getInteractionSupport(node.localName);
|
|
447
|
+
if (support?.support === "deprecated") {
|
|
448
|
+
diagnostics.push({
|
|
449
|
+
code: "interaction.deprecated",
|
|
450
|
+
severity: "warning",
|
|
451
|
+
message: `${node.localName} is deprecated. ${support.notes ?? ""}`.trim(),
|
|
452
|
+
path: node.source.path,
|
|
453
|
+
source: node.source,
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const objectNode =
|
|
458
|
+
interactionType === "positionObject"
|
|
459
|
+
? positionObjectInteractionObject(node)
|
|
460
|
+
: interactionType === "media"
|
|
461
|
+
? mediaInteractionObject(node)
|
|
462
|
+
: interactionType === "drawing"
|
|
463
|
+
? drawingInteractionObject(node)
|
|
464
|
+
: descendants(
|
|
465
|
+
node,
|
|
466
|
+
(child) => child.localName === "object" || child.localName === "img",
|
|
467
|
+
)[0];
|
|
468
|
+
|
|
469
|
+
return {
|
|
470
|
+
type: interactionType ?? "custom",
|
|
471
|
+
qtiName: node.localName,
|
|
472
|
+
responseIdentifier,
|
|
473
|
+
responseCardinality: responseDeclaration?.cardinality,
|
|
474
|
+
responseBaseType: responseDeclaration?.baseType,
|
|
475
|
+
prompt: prompt ? textContent(prompt) : undefined,
|
|
476
|
+
promptAttributes: prompt?.attributes,
|
|
477
|
+
promptSource: prompt?.source,
|
|
478
|
+
contextText: inlineInteractionContext(node, interactionType),
|
|
479
|
+
object: parseObjectAsset(objectNode),
|
|
480
|
+
positionObjectStage:
|
|
481
|
+
interactionType === "positionObject"
|
|
482
|
+
? parseObjectAsset(positionObjectStageObject(node))
|
|
483
|
+
: undefined,
|
|
484
|
+
portableCustom:
|
|
485
|
+
interactionType === "portableCustom"
|
|
486
|
+
? parsePortableCustomDefinition(node, diagnostics)
|
|
487
|
+
: undefined,
|
|
488
|
+
choices: parseChoices(node),
|
|
489
|
+
hottextSegments: interactionType === "hottext" ? parseHottextSegments(node) : undefined,
|
|
490
|
+
gapMatchSegments:
|
|
491
|
+
interactionType === "gapMatch" || interactionType === "graphicGapMatch"
|
|
492
|
+
? parseGapMatchSegments(node)
|
|
493
|
+
: undefined,
|
|
494
|
+
childElements: childElements(node).map((child) => ({
|
|
495
|
+
qtiName: child.localName,
|
|
496
|
+
source: child.source,
|
|
497
|
+
})),
|
|
498
|
+
attributes: node.attributes,
|
|
499
|
+
text: textContent(node),
|
|
500
|
+
source: node.source,
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function parsePortableCustomDefinition(
|
|
505
|
+
node: XmlNode,
|
|
506
|
+
diagnostics: QtiDiagnostic[],
|
|
507
|
+
): QtiPortableCustomDefinition {
|
|
508
|
+
const markup = firstChildElement(node, "qti-interaction-markup", diagnostics);
|
|
509
|
+
const modules = firstChildElement(node, "qti-interaction-modules", diagnostics);
|
|
510
|
+
return {
|
|
511
|
+
responseIdentifier: node.attributes["response-identifier"],
|
|
512
|
+
customInteractionTypeIdentifier: node.attributes["custom-interaction-type-identifier"],
|
|
513
|
+
module: node.attributes.module,
|
|
514
|
+
interactionModules: modules ? parsePortableCustomInteractionModules(modules) : undefined,
|
|
515
|
+
interactionMarkup: markup ? parsePortableCustomMarkupChildren(markup, diagnostics) : [],
|
|
516
|
+
interactionMarkupRaw: markup ? serializeXmlContent(markup) : undefined,
|
|
517
|
+
templateVariables: childElements(node, "qti-template-variable").map((variable) =>
|
|
518
|
+
parsePortableCustomVariableBinding(variable, "template"),
|
|
519
|
+
),
|
|
520
|
+
contextVariables: childElements(node, "qti-context-variable").map((variable) =>
|
|
521
|
+
parsePortableCustomVariableBinding(variable, "context"),
|
|
522
|
+
),
|
|
523
|
+
stylesheets: childElements(node, "qti-stylesheet").map(parseStylesheet),
|
|
524
|
+
catalogInfo: parseCatalogInfo(childElements(node, "qti-catalog-info")[0]),
|
|
525
|
+
dataAttributes: Object.fromEntries(
|
|
526
|
+
Object.entries(node.attributes).filter(([name]) => name.startsWith("data-")),
|
|
527
|
+
),
|
|
528
|
+
attributes: node.attributes,
|
|
529
|
+
source: node.source,
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function firstChildElement(
|
|
534
|
+
node: XmlNode,
|
|
535
|
+
name: string,
|
|
536
|
+
diagnostics: QtiDiagnostic[],
|
|
537
|
+
): XmlNode | undefined {
|
|
538
|
+
const matches = childElements(node, name);
|
|
539
|
+
if (matches.length > 1) {
|
|
540
|
+
diagnostics.push({
|
|
541
|
+
code: "interaction.portableCustom.child.duplicate",
|
|
542
|
+
severity: "error",
|
|
543
|
+
message: `${node.localName} allows at most one ${name} child.`,
|
|
544
|
+
path: matches[1]?.source?.path ?? node.source?.path,
|
|
545
|
+
source: matches[1]?.source ?? node.source,
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
return matches[0];
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function parsePortableCustomMarkupChildren(
|
|
552
|
+
node: XmlNode,
|
|
553
|
+
diagnostics: QtiDiagnostic[],
|
|
554
|
+
): QtiContentNode[] {
|
|
555
|
+
const content: QtiContentNode[] = [];
|
|
556
|
+
for (const entry of node.content) {
|
|
557
|
+
if (typeof entry === "string") {
|
|
558
|
+
if (entry.length > 0) content.push({ kind: "text", text: entry, source: node.source });
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
content.push(parsePortableCustomMarkupNode(entry, diagnostics));
|
|
562
|
+
}
|
|
563
|
+
return content;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function parsePortableCustomMarkupNode(
|
|
567
|
+
node: XmlNode,
|
|
568
|
+
diagnostics: QtiDiagnostic[],
|
|
569
|
+
): QtiContentNode {
|
|
570
|
+
if (isInteractionElement(node)) {
|
|
571
|
+
diagnostics.push({
|
|
572
|
+
code: "interaction.portableCustom.markupInteraction",
|
|
573
|
+
severity: "error",
|
|
574
|
+
message: `qti-interaction-markup must not contain nested QTI interaction ${node.localName}.`,
|
|
575
|
+
path: node.source?.path,
|
|
576
|
+
source: node.source,
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
if (node.localName === "qti-printed-variable") {
|
|
581
|
+
return {
|
|
582
|
+
kind: "printedVariable",
|
|
583
|
+
identifier: node.attributes.identifier ?? "",
|
|
584
|
+
format: node.attributes.format,
|
|
585
|
+
attributes: node.attributes,
|
|
586
|
+
source: node.source,
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
if (node.localName === "qti-feedback-block" || node.localName === "qti-feedback-inline") {
|
|
591
|
+
return {
|
|
592
|
+
kind: "feedback",
|
|
593
|
+
feedbackType: node.localName === "qti-feedback-block" ? "block" : "inline",
|
|
594
|
+
identifier: node.attributes.identifier ?? "",
|
|
595
|
+
outcomeIdentifier: node.attributes["outcome-identifier"] ?? "",
|
|
596
|
+
showHide: node.attributes["show-hide"] === "hide" ? "hide" : "show",
|
|
597
|
+
attributes: node.attributes,
|
|
598
|
+
children: parsePortableCustomMarkupChildren(node, diagnostics),
|
|
599
|
+
source: node.source,
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
return {
|
|
604
|
+
kind: "element",
|
|
605
|
+
qtiName: node.localName,
|
|
606
|
+
attributes: node.attributes,
|
|
607
|
+
children: parsePortableCustomMarkupChildren(node, diagnostics),
|
|
608
|
+
source: node.source,
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function parsePortableCustomInteractionModules(node: XmlNode): QtiPortableCustomInteractionModules {
|
|
613
|
+
return {
|
|
614
|
+
primaryConfiguration: node.attributes["primary-configuration"],
|
|
615
|
+
secondaryConfiguration:
|
|
616
|
+
node.attributes["secondary-configuration"] ?? node.attributes["fallback-configuration"],
|
|
617
|
+
modules: childElements(node, "qti-interaction-module").map(
|
|
618
|
+
parsePortableCustomInteractionModule,
|
|
619
|
+
),
|
|
620
|
+
attributes: node.attributes,
|
|
621
|
+
source: node.source,
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function parsePortableCustomInteractionModule(node: XmlNode): QtiPortableCustomInteractionModule {
|
|
626
|
+
return {
|
|
627
|
+
id: node.attributes.id,
|
|
628
|
+
primaryPath: node.attributes["primary-path"],
|
|
629
|
+
fallbackPath: node.attributes["fallback-path"],
|
|
630
|
+
attributes: node.attributes,
|
|
631
|
+
source: node.source,
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function parsePortableCustomVariableBinding(
|
|
636
|
+
node: XmlNode,
|
|
637
|
+
kind: QtiPortableCustomVariableBinding["kind"],
|
|
638
|
+
): QtiPortableCustomVariableBinding {
|
|
639
|
+
return {
|
|
640
|
+
kind,
|
|
641
|
+
identifier: node.attributes.identifier ?? node.attributes["template-identifier"],
|
|
642
|
+
variableIdentifier: node.attributes["variable-identifier"],
|
|
643
|
+
attributes: node.attributes,
|
|
644
|
+
source: node.source,
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function serializeXmlContent(node: XmlNode): string {
|
|
649
|
+
return node.content
|
|
650
|
+
.map((entry) => (typeof entry === "string" ? escapeXmlText(entry) : serializeXmlNode(entry)))
|
|
651
|
+
.join("");
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function serializeXmlNode(node: XmlNode): string {
|
|
655
|
+
const attributes = Object.entries(node.attributes)
|
|
656
|
+
.map(([name, value]) => ` ${name}="${escapeXmlAttribute(value)}"`)
|
|
657
|
+
.join("");
|
|
658
|
+
if (node.content.length === 0) return `<${node.name}${attributes}/>`;
|
|
659
|
+
return `<${node.name}${attributes}>${serializeXmlContent(node)}</${node.name}>`;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function escapeXmlText(value: string): string {
|
|
663
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function escapeXmlAttribute(value: string): string {
|
|
667
|
+
return escapeXmlText(value).replaceAll('"', """);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function positionObjectInteractionObject(node: XmlNode): XmlNode | undefined {
|
|
671
|
+
return childElements(node).find(
|
|
672
|
+
(child) => child.localName === "object" || child.localName === "img",
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function mediaInteractionObject(node: XmlNode): XmlNode | undefined {
|
|
677
|
+
return childElements(node).find(
|
|
678
|
+
(child) =>
|
|
679
|
+
child.localName === "audio" ||
|
|
680
|
+
child.localName === "video" ||
|
|
681
|
+
child.localName === "object" ||
|
|
682
|
+
child.localName === "img",
|
|
683
|
+
);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function drawingInteractionObject(node: XmlNode): XmlNode | undefined {
|
|
687
|
+
return childElements(node).find(
|
|
688
|
+
(child) =>
|
|
689
|
+
child.localName === "object" || child.localName === "img" || child.localName === "picture",
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function positionObjectStageObject(node: XmlNode): XmlNode | undefined {
|
|
694
|
+
const ancestorStage = nearestAncestor(node, "qti-position-object-stage");
|
|
695
|
+
const stage = ancestorStage ?? childElements(node, "qti-position-object-stage")[0];
|
|
696
|
+
if (!stage) return undefined;
|
|
697
|
+
return childElements(stage).find(
|
|
698
|
+
(child) => child.localName === "object" || child.localName === "img",
|
|
699
|
+
);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function nearestAncestor(node: XmlNode, localName: string): XmlNode | undefined {
|
|
703
|
+
for (let parent = node.parent; parent; parent = parent.parent) {
|
|
704
|
+
if (parent.localName === localName) return parent;
|
|
705
|
+
}
|
|
706
|
+
return undefined;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function parseHottextSegments(node: XmlNode): QtiInteraction["hottextSegments"] {
|
|
710
|
+
const segments: NonNullable<QtiInteraction["hottextSegments"]> = [];
|
|
711
|
+
|
|
712
|
+
const visit = (entry: string | XmlNode): void => {
|
|
713
|
+
if (typeof entry === "string") {
|
|
714
|
+
const text = entry.replace(/\s+/g, " ");
|
|
715
|
+
if (text.trim().length > 0) segments.push({ kind: "text", text });
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
if (entry.localName === "qti-prompt") return;
|
|
720
|
+
|
|
721
|
+
if (entry.localName === "qti-hottext") {
|
|
722
|
+
segments.push({
|
|
723
|
+
kind: "hottext",
|
|
724
|
+
identifier: entry.attributes.identifier ?? "",
|
|
725
|
+
text: textContent(entry),
|
|
726
|
+
attributes: entry.attributes,
|
|
727
|
+
source: entry.source,
|
|
728
|
+
});
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
for (const child of entry.content) visit(child);
|
|
733
|
+
if (entry.localName === "p" || entry.localName === "div") {
|
|
734
|
+
segments.push({ kind: "text", text: " " });
|
|
735
|
+
}
|
|
736
|
+
};
|
|
737
|
+
|
|
738
|
+
for (const entry of node.content) visit(entry);
|
|
739
|
+
return segments;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function parseGapMatchSegments(node: XmlNode): QtiInteraction["gapMatchSegments"] {
|
|
743
|
+
const segments: NonNullable<QtiInteraction["gapMatchSegments"]> = [];
|
|
744
|
+
|
|
745
|
+
const visit = (entry: string | XmlNode): void => {
|
|
746
|
+
if (typeof entry === "string") {
|
|
747
|
+
const text = entry.replace(/\s+/g, " ");
|
|
748
|
+
if (text.trim().length > 0) segments.push({ kind: "text", text });
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
if (entry.localName === "qti-prompt") return;
|
|
753
|
+
if (entry.localName === "qti-gap-text" || entry.localName === "qti-gap-img") return;
|
|
754
|
+
if (entry.localName === "object" || entry.localName === "img") return;
|
|
755
|
+
|
|
756
|
+
if (entry.localName === "qti-gap") {
|
|
757
|
+
segments.push({
|
|
758
|
+
kind: "gap",
|
|
759
|
+
identifier: entry.attributes.identifier ?? "",
|
|
760
|
+
attributes: entry.attributes,
|
|
761
|
+
source: entry.source,
|
|
762
|
+
});
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
for (const child of entry.content) visit(child);
|
|
767
|
+
if (entry.localName === "p" || entry.localName === "div") {
|
|
768
|
+
segments.push({ kind: "text", text: " " });
|
|
769
|
+
}
|
|
770
|
+
};
|
|
771
|
+
|
|
772
|
+
for (const entry of node.content) visit(entry);
|
|
773
|
+
return segments;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function inlineInteractionContext(
|
|
777
|
+
node: XmlNode,
|
|
778
|
+
interactionType: QtiInteractionType | undefined,
|
|
779
|
+
): string | undefined {
|
|
780
|
+
if (interactionType !== "inlineChoice" && interactionType !== "textEntry") return undefined;
|
|
781
|
+
const parent = node.parent;
|
|
782
|
+
if (!parent) return undefined;
|
|
783
|
+
return normalizeInlineContext(parent.text) ?? normalizeInlineContext(textContent(parent));
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
function normalizeInlineContext(value: string): string | undefined {
|
|
787
|
+
const normalized = value
|
|
788
|
+
.replace(/\s+/g, " ")
|
|
789
|
+
.replace(/\s+([.,;:!?])/g, "$1")
|
|
790
|
+
.trim();
|
|
791
|
+
return normalized.length > 0 ? normalized : undefined;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
function parseObjectAsset(node: XmlNode | undefined): QtiObjectAsset | undefined {
|
|
795
|
+
if (!node) return undefined;
|
|
796
|
+
const pictureImage = node.localName === "picture" ? childElements(node, "img")[0] : undefined;
|
|
797
|
+
const data = node.attributes.data ?? node.attributes.src ?? pictureImage?.attributes.src;
|
|
798
|
+
const sources = parseMediaSources(node);
|
|
799
|
+
const tracks = parseMediaTracks(node);
|
|
800
|
+
const inferredSvgDimensions = inlineSvgDimensions(data);
|
|
801
|
+
return {
|
|
802
|
+
data,
|
|
803
|
+
type:
|
|
804
|
+
node.attributes.type ??
|
|
805
|
+
pictureImage?.attributes.type ??
|
|
806
|
+
assetTypeFromData(data) ??
|
|
807
|
+
firstSourceType(sources),
|
|
808
|
+
width: node.attributes.width ?? pictureImage?.attributes.width ?? inferredSvgDimensions?.width,
|
|
809
|
+
height:
|
|
810
|
+
node.attributes.height ?? pictureImage?.attributes.height ?? inferredSvgDimensions?.height,
|
|
811
|
+
sources,
|
|
812
|
+
tracks,
|
|
813
|
+
text: textContent(node) || node.attributes.alt || pictureImage?.attributes.alt || "",
|
|
814
|
+
attributes: node.attributes,
|
|
815
|
+
source: node.source,
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function inlineSvgDimensions(
|
|
820
|
+
data: string | undefined,
|
|
821
|
+
): { width: string; height: string } | undefined {
|
|
822
|
+
const svgText = decodeInlineSvgData(data);
|
|
823
|
+
if (!svgText) return undefined;
|
|
824
|
+
const tree = parseXmlTree(svgText);
|
|
825
|
+
const svg = tree.root;
|
|
826
|
+
if (!svg || svg.localName !== "svg") return undefined;
|
|
827
|
+
|
|
828
|
+
const width = svgLength(svg.attributes.width);
|
|
829
|
+
const height = svgLength(svg.attributes.height);
|
|
830
|
+
if (width !== undefined && height !== undefined) {
|
|
831
|
+
return { width: formatDimension(width), height: formatDimension(height) };
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
const viewBox = svgViewBoxDimensions(svg.attributes.viewBox);
|
|
835
|
+
if (!viewBox) return undefined;
|
|
836
|
+
const inferredWidth = width ?? viewBox.width;
|
|
837
|
+
const inferredHeight = height ?? viewBox.height;
|
|
838
|
+
if (inferredWidth <= 0 || inferredHeight <= 0) return undefined;
|
|
839
|
+
return { width: formatDimension(inferredWidth), height: formatDimension(inferredHeight) };
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
function decodeInlineSvgData(data: string | undefined): string | undefined {
|
|
843
|
+
if (!data) return undefined;
|
|
844
|
+
const comma = data.indexOf(",");
|
|
845
|
+
if (comma < 0) return undefined;
|
|
846
|
+
const metadata = data.slice(0, comma);
|
|
847
|
+
if (!/^data:image\/svg\+xml(?:[;,]|$)/i.test(metadata)) return undefined;
|
|
848
|
+
const payload = data.slice(comma + 1);
|
|
849
|
+
if (/;base64(?:[;,]|$)/i.test(metadata)) return decodeBase64Ascii(payload);
|
|
850
|
+
try {
|
|
851
|
+
return decodeURIComponent(payload);
|
|
852
|
+
} catch {
|
|
853
|
+
return payload;
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
function decodeBase64Ascii(value: string): string | undefined {
|
|
858
|
+
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
859
|
+
const cleaned = value.replace(/\s+/g, "").replace(/=+$/, "");
|
|
860
|
+
let bits = 0;
|
|
861
|
+
let buffer = 0;
|
|
862
|
+
let output = "";
|
|
863
|
+
for (const char of cleaned) {
|
|
864
|
+
const digit = alphabet.indexOf(char);
|
|
865
|
+
if (digit < 0) return undefined;
|
|
866
|
+
buffer = (buffer << 6) | digit;
|
|
867
|
+
bits += 6;
|
|
868
|
+
if (bits < 8) continue;
|
|
869
|
+
bits -= 8;
|
|
870
|
+
output += String.fromCharCode((buffer >> bits) & 0xff);
|
|
871
|
+
}
|
|
872
|
+
return output;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
function svgLength(value: string | undefined): number | undefined {
|
|
876
|
+
if (!value || value.trim().endsWith("%")) return undefined;
|
|
877
|
+
const match = value.trim().match(/^(\d+(?:\.\d+)?|\.\d+)(?:px)?$/i);
|
|
878
|
+
if (!match?.[1]) return undefined;
|
|
879
|
+
const parsed = Number(match[1]);
|
|
880
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
function svgViewBoxDimensions(
|
|
884
|
+
value: string | undefined,
|
|
885
|
+
): { width: number; height: number } | undefined {
|
|
886
|
+
const parts = value
|
|
887
|
+
?.trim()
|
|
888
|
+
.split(/[\s,]+/)
|
|
889
|
+
.map((part) => Number(part));
|
|
890
|
+
if (!parts || parts.length !== 4 || parts.some((part) => !Number.isFinite(part))) {
|
|
891
|
+
return undefined;
|
|
892
|
+
}
|
|
893
|
+
const width = parts[2];
|
|
894
|
+
const height = parts[3];
|
|
895
|
+
if (width === undefined || height === undefined || width <= 0 || height <= 0) return undefined;
|
|
896
|
+
return { width, height };
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
function formatDimension(value: number): string {
|
|
900
|
+
return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(4)));
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function parseMediaSources(node: XmlNode): QtiMediaSource[] {
|
|
904
|
+
return childElements(node, "source").map((source) => {
|
|
905
|
+
const src = source.attributes.src ?? firstSrcsetCandidate(source.attributes.srcset);
|
|
906
|
+
return {
|
|
907
|
+
src,
|
|
908
|
+
type: source.attributes.type ?? assetTypeFromData(src),
|
|
909
|
+
attributes: source.attributes,
|
|
910
|
+
source: source.source,
|
|
911
|
+
};
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
function parseMediaTracks(node: XmlNode): QtiObjectAsset["tracks"] {
|
|
916
|
+
return childElements(node, "track").map((track) => ({
|
|
917
|
+
kind: track.attributes.kind,
|
|
918
|
+
src: track.attributes.src,
|
|
919
|
+
srclang: track.attributes.srclang,
|
|
920
|
+
label: track.attributes.label,
|
|
921
|
+
default: track.attributes.default !== undefined,
|
|
922
|
+
attributes: track.attributes,
|
|
923
|
+
source: track.source,
|
|
924
|
+
}));
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
function firstSourceType(sources: QtiMediaSource[]): string | undefined {
|
|
928
|
+
const explicitType = sources.find((source) => source.type)?.type;
|
|
929
|
+
if (explicitType) return explicitType;
|
|
930
|
+
return sources.find((source) => source.src)?.src
|
|
931
|
+
? assetTypeFromData(sources.find((source) => source.src)?.src)
|
|
932
|
+
: undefined;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
function firstSrcsetCandidate(srcset: string | undefined): string | undefined {
|
|
936
|
+
return srcset
|
|
937
|
+
?.split(",")
|
|
938
|
+
.map((candidate) => candidate.trim().split(/\s+/)[0])
|
|
939
|
+
.find((candidate) => candidate && candidate.length > 0);
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
function assetTypeFromData(data: string | undefined): string | undefined {
|
|
943
|
+
if (!data) return undefined;
|
|
944
|
+
if (data.startsWith("data:image/svg+xml")) return "image/svg+xml";
|
|
945
|
+
if (data.startsWith("data:image/")) return "image/*";
|
|
946
|
+
if (data.startsWith("data:audio/")) return "audio/*";
|
|
947
|
+
if (data.startsWith("data:video/")) return "video/*";
|
|
948
|
+
if (/\.(svg|png|jpg|jpeg|gif|webp)(?:[?#].*)?$/i.test(data)) return "image/*";
|
|
949
|
+
if (/\.(aac|flac|m4a|mp3|oga|ogg|opus|wav)(?:[?#].*)?$/i.test(data)) return "audio/*";
|
|
950
|
+
if (/\.(m4v|mov|mp4|ogv|webm)(?:[?#].*)?$/i.test(data)) return "video/*";
|
|
951
|
+
return undefined;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
function parseChoices(node: XmlNode): QtiChoice[] {
|
|
955
|
+
const choiceNames = new Set([
|
|
956
|
+
"qti-simple-choice",
|
|
957
|
+
"qti-simple-associable-choice",
|
|
958
|
+
"qti-inline-choice",
|
|
959
|
+
"qti-gap-text",
|
|
960
|
+
"qti-gap-img",
|
|
961
|
+
"qti-hottext",
|
|
962
|
+
"qti-hotspot-choice",
|
|
963
|
+
"qti-associable-hotspot",
|
|
964
|
+
"qti-gap",
|
|
965
|
+
]);
|
|
966
|
+
|
|
967
|
+
return descendants(node, (child) => choiceNames.has(child.localName)).map((choice, index) => {
|
|
968
|
+
const identifier = choice.attributes.identifier ?? "";
|
|
969
|
+
return {
|
|
970
|
+
identifier,
|
|
971
|
+
text: textContent(choice) || identifier || `Choice ${index + 1}`,
|
|
972
|
+
role: choiceRole(choice),
|
|
973
|
+
qtiName: choice.localName,
|
|
974
|
+
attributes: choice.attributes,
|
|
975
|
+
source: choice.source,
|
|
976
|
+
};
|
|
977
|
+
});
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
function choiceRole(node: XmlNode): QtiChoiceRole {
|
|
981
|
+
if (node.localName === "qti-simple-choice") return "simpleChoice";
|
|
982
|
+
if (node.localName === "qti-inline-choice") return "inlineChoice";
|
|
983
|
+
if (node.localName === "qti-gap-text" || node.localName === "qti-gap-img") return "gapChoice";
|
|
984
|
+
if (node.localName === "qti-gap") return "gap";
|
|
985
|
+
if (node.localName === "qti-hottext") return "hottext";
|
|
986
|
+
if (node.localName === "qti-hotspot-choice" || node.localName === "qti-associable-hotspot") {
|
|
987
|
+
return "hotspot";
|
|
988
|
+
}
|
|
989
|
+
if (node.localName === "qti-simple-associable-choice") {
|
|
990
|
+
const matchSet = node.parent?.localName === "qti-simple-match-set" ? node.parent : undefined;
|
|
991
|
+
const interaction = nearestInteraction(node);
|
|
992
|
+
if (matchSet && interaction?.localName === "qti-match-interaction") {
|
|
993
|
+
return matchSetIndex(matchSet) === 0 ? "matchSource" : "matchTarget";
|
|
994
|
+
}
|
|
995
|
+
return "associableChoice";
|
|
996
|
+
}
|
|
997
|
+
return "simpleChoice";
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
function nearestInteraction(node: XmlNode): XmlNode | undefined {
|
|
1001
|
+
for (let parent = node.parent; parent; parent = parent.parent) {
|
|
1002
|
+
if (interactionNameToType.has(parent.localName)) return parent;
|
|
1003
|
+
}
|
|
1004
|
+
return undefined;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
function matchSetIndex(node: XmlNode): number {
|
|
1008
|
+
const siblings =
|
|
1009
|
+
node.parent?.children.filter((sibling) => sibling.localName === "qti-simple-match-set") ?? [];
|
|
1010
|
+
return siblings.indexOf(node);
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
function parseVariableValue(
|
|
1014
|
+
node: XmlNode | undefined,
|
|
1015
|
+
baseType: QtiBaseType | undefined,
|
|
1016
|
+
): QtiValue {
|
|
1017
|
+
if (!node) return null;
|
|
1018
|
+
const valueNodes = childElements(node, "qti-value");
|
|
1019
|
+
const entries = valueNodes.map((valueNode) => ({
|
|
1020
|
+
fieldIdentifier: valueNode.attributes["field-identifier"],
|
|
1021
|
+
value: coerceValue(textContent(valueNode), valueNode.attributes["base-type"] ?? baseType),
|
|
1022
|
+
}));
|
|
1023
|
+
const recordEntries = entries.filter(
|
|
1024
|
+
(entry): entry is { fieldIdentifier: string; value: QtiScalarValue } =>
|
|
1025
|
+
Boolean(entry.fieldIdentifier),
|
|
1026
|
+
);
|
|
1027
|
+
if (recordEntries.length > 0) {
|
|
1028
|
+
return Object.fromEntries(recordEntries.map((entry) => [entry.fieldIdentifier, entry.value]));
|
|
1029
|
+
}
|
|
1030
|
+
if (entries.length === 0) {
|
|
1031
|
+
const text = textContent(node);
|
|
1032
|
+
return text.length > 0 ? coerceValue(text, baseType) : null;
|
|
1033
|
+
}
|
|
1034
|
+
if (entries.length === 1) return entries[0]?.value ?? null;
|
|
1035
|
+
return entries.map((entry) => entry.value);
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
function parseMapping(node: XmlNode | undefined): QtiResponseDeclaration["mapping"] | undefined {
|
|
1039
|
+
if (!node) return undefined;
|
|
1040
|
+
return {
|
|
1041
|
+
defaultValue: Number(node.attributes["default-value"] ?? 0),
|
|
1042
|
+
attributes: node.attributes,
|
|
1043
|
+
source: node.source,
|
|
1044
|
+
entries: childElements(node, "qti-map-entry").map((entry) => ({
|
|
1045
|
+
mapKey: entry.attributes["map-key"],
|
|
1046
|
+
mappedValue: Number(entry.attributes["mapped-value"] ?? 0),
|
|
1047
|
+
attributes: entry.attributes,
|
|
1048
|
+
source: entry.source,
|
|
1049
|
+
})),
|
|
1050
|
+
};
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
function parseAreaMapping(
|
|
1054
|
+
node: XmlNode | undefined,
|
|
1055
|
+
): QtiResponseDeclaration["areaMapping"] | undefined {
|
|
1056
|
+
if (!node) return undefined;
|
|
1057
|
+
return {
|
|
1058
|
+
defaultValue: Number(node.attributes["default-value"] ?? 0),
|
|
1059
|
+
attributes: node.attributes,
|
|
1060
|
+
source: node.source,
|
|
1061
|
+
entries: childElements(node, "qti-area-map-entry").map((entry) => ({
|
|
1062
|
+
shape: parseShape(entry.attributes.shape),
|
|
1063
|
+
coords: parseCoords(entry.attributes.coords),
|
|
1064
|
+
mappedValue: Number(entry.attributes["mapped-value"] ?? 0),
|
|
1065
|
+
attributes: entry.attributes,
|
|
1066
|
+
source: entry.source,
|
|
1067
|
+
})),
|
|
1068
|
+
};
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
function parseLookupTable(
|
|
1072
|
+
node: XmlNode,
|
|
1073
|
+
baseType: QtiOutcomeDeclaration["baseType"],
|
|
1074
|
+
): QtiLookupTable | undefined {
|
|
1075
|
+
const matchTable = childElements(node, "qti-match-table")[0];
|
|
1076
|
+
if (matchTable) return parseMatchTable(matchTable, baseType);
|
|
1077
|
+
const interpolationTable = childElements(node, "qti-interpolation-table")[0];
|
|
1078
|
+
if (interpolationTable) return parseInterpolationTable(interpolationTable, baseType);
|
|
1079
|
+
return undefined;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
function parseMatchTable(
|
|
1083
|
+
node: XmlNode,
|
|
1084
|
+
baseType: QtiOutcomeDeclaration["baseType"],
|
|
1085
|
+
): QtiLookupTable {
|
|
1086
|
+
return {
|
|
1087
|
+
type: "match",
|
|
1088
|
+
defaultValue: parseLookupValue(node.attributes["default-value"], baseType),
|
|
1089
|
+
attributes: node.attributes,
|
|
1090
|
+
source: node.source,
|
|
1091
|
+
entries: childElements(node, "qti-match-table-entry").map((entry) => ({
|
|
1092
|
+
sourceValue: Number(entry.attributes["source-value"]),
|
|
1093
|
+
targetValue: parseLookupValue(entry.attributes["target-value"], baseType),
|
|
1094
|
+
attributes: entry.attributes,
|
|
1095
|
+
source: entry.source,
|
|
1096
|
+
})),
|
|
1097
|
+
};
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
function parseInterpolationTable(
|
|
1101
|
+
node: XmlNode,
|
|
1102
|
+
baseType: QtiOutcomeDeclaration["baseType"],
|
|
1103
|
+
): QtiLookupTable {
|
|
1104
|
+
return {
|
|
1105
|
+
type: "interpolation",
|
|
1106
|
+
defaultValue: parseLookupValue(node.attributes["default-value"], baseType),
|
|
1107
|
+
attributes: node.attributes,
|
|
1108
|
+
source: node.source,
|
|
1109
|
+
entries: childElements(node, "qti-interpolation-table-entry").map((entry) => ({
|
|
1110
|
+
sourceValue: Number(entry.attributes["source-value"]),
|
|
1111
|
+
targetValue: parseLookupValue(entry.attributes["target-value"], baseType),
|
|
1112
|
+
includeBoundary: entry.attributes["include-boundary"] !== "false",
|
|
1113
|
+
attributes: entry.attributes,
|
|
1114
|
+
source: entry.source,
|
|
1115
|
+
})),
|
|
1116
|
+
};
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
function parseLookupValue(value: string | undefined, baseType: string | undefined): QtiValue {
|
|
1120
|
+
return value === undefined ? null : coerceValue(value, baseType);
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
function parseShape(
|
|
1124
|
+
shape: string | undefined,
|
|
1125
|
+
): NonNullable<QtiResponseDeclaration["areaMapping"]>["entries"][number]["shape"] {
|
|
1126
|
+
if (shape === "circle" || shape === "rect" || shape === "poly") return shape;
|
|
1127
|
+
return "default";
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
function parseCoords(value: string | undefined): number[] {
|
|
1131
|
+
return (value ?? "")
|
|
1132
|
+
.split(",")
|
|
1133
|
+
.map((part) => Number(part.trim()))
|
|
1134
|
+
.filter((part) => Number.isFinite(part));
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
function parseResponseProcessing(node: XmlNode | undefined): QtiResponseProcessing | undefined {
|
|
1138
|
+
if (!node) return undefined;
|
|
1139
|
+
return {
|
|
1140
|
+
template: node.attributes.template,
|
|
1141
|
+
rules: parseResponseRules(node),
|
|
1142
|
+
conditions: responseConditionsFromChildren(node),
|
|
1143
|
+
};
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
function parseTemplateProcessing(node: XmlNode | undefined): QtiTemplateProcessing | undefined {
|
|
1147
|
+
if (!node) return undefined;
|
|
1148
|
+
return {
|
|
1149
|
+
rules: parseTemplateRules(node),
|
|
1150
|
+
};
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
function parseTemplateRules(node: XmlNode): QtiTemplateRule[] {
|
|
1154
|
+
return childElements(node)
|
|
1155
|
+
.map(parseTemplateRule)
|
|
1156
|
+
.filter((rule): rule is QtiTemplateRule => rule !== undefined);
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
function parseTemplateRule(node: XmlNode): QtiTemplateRule | undefined {
|
|
1160
|
+
if (node.localName === "qti-set-template-value") {
|
|
1161
|
+
return {
|
|
1162
|
+
type: "setTemplateValue",
|
|
1163
|
+
identifier: node.attributes.identifier ?? "",
|
|
1164
|
+
expression: parseFirstExpression(node) ?? {
|
|
1165
|
+
type: "baseValue",
|
|
1166
|
+
value: null,
|
|
1167
|
+
source: node.source,
|
|
1168
|
+
},
|
|
1169
|
+
source: node.source,
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
if (node.localName === "qti-set-default-value") {
|
|
1174
|
+
return {
|
|
1175
|
+
type: "setDefaultValue",
|
|
1176
|
+
identifier: node.attributes.identifier ?? "",
|
|
1177
|
+
expression: parseFirstExpression(node) ?? {
|
|
1178
|
+
type: "baseValue",
|
|
1179
|
+
value: null,
|
|
1180
|
+
source: node.source,
|
|
1181
|
+
},
|
|
1182
|
+
source: node.source,
|
|
1183
|
+
};
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
if (node.localName === "qti-set-correct-response") {
|
|
1187
|
+
return {
|
|
1188
|
+
type: "setCorrectResponse",
|
|
1189
|
+
identifier: node.attributes.identifier ?? "",
|
|
1190
|
+
expression: parseFirstExpression(node) ?? {
|
|
1191
|
+
type: "baseValue",
|
|
1192
|
+
value: null,
|
|
1193
|
+
source: node.source,
|
|
1194
|
+
},
|
|
1195
|
+
source: node.source,
|
|
1196
|
+
};
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
if (node.localName === "qti-template-condition") {
|
|
1200
|
+
const templateIf = childElements(node, "qti-template-if")[0];
|
|
1201
|
+
const templateElse = childElements(node, "qti-template-else")[0];
|
|
1202
|
+
return {
|
|
1203
|
+
type: "templateCondition",
|
|
1204
|
+
ifExpression: templateIf ? parseFirstExpression(templateIf) : undefined,
|
|
1205
|
+
thenRules: templateIf ? parseTemplateRules(templateIf) : [],
|
|
1206
|
+
elseIfs: childElements(node, "qti-template-else-if").map((branch) => ({
|
|
1207
|
+
expression: parseFirstExpression(branch),
|
|
1208
|
+
rules: parseTemplateRules(branch),
|
|
1209
|
+
})),
|
|
1210
|
+
elseRules: templateElse ? parseTemplateRules(templateElse) : [],
|
|
1211
|
+
source: node.source,
|
|
1212
|
+
};
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
if (node.localName === "qti-exit-template") {
|
|
1216
|
+
return {
|
|
1217
|
+
type: "exitTemplate",
|
|
1218
|
+
source: node.source,
|
|
1219
|
+
};
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
if (node.localName === "qti-template-constraint") {
|
|
1223
|
+
return {
|
|
1224
|
+
type: "templateConstraint",
|
|
1225
|
+
expression: parseFirstExpression(node) ?? {
|
|
1226
|
+
type: "baseValue",
|
|
1227
|
+
value: null,
|
|
1228
|
+
source: node.source,
|
|
1229
|
+
},
|
|
1230
|
+
source: node.source,
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
return undefined;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
function responseConditionsFromChildren(node: XmlNode): QtiResponseCondition[] {
|
|
1238
|
+
return childElements(node).flatMap((child) => {
|
|
1239
|
+
if (child.localName === "qti-response-condition") return [parseResponseCondition(child)];
|
|
1240
|
+
if (child.localName === "qti-response-processing-fragment") {
|
|
1241
|
+
return responseConditionsFromChildren(child);
|
|
1242
|
+
}
|
|
1243
|
+
return [];
|
|
1244
|
+
});
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
function parseResponseCondition(node: XmlNode): QtiResponseCondition {
|
|
1248
|
+
const responseIf = childElements(node, "qti-response-if")[0];
|
|
1249
|
+
const responseElse = childElements(node, "qti-response-else")[0];
|
|
1250
|
+
return {
|
|
1251
|
+
ifExpression: responseIf ? parseFirstExpression(responseIf) : undefined,
|
|
1252
|
+
thenRules: responseIf ? parseResponseRules(responseIf) : [],
|
|
1253
|
+
elseIfs: childElements(node, "qti-response-else-if").map((branch) => ({
|
|
1254
|
+
expression: parseFirstExpression(branch),
|
|
1255
|
+
rules: parseResponseRules(branch),
|
|
1256
|
+
})),
|
|
1257
|
+
elseRules: responseElse ? parseResponseRules(responseElse) : [],
|
|
1258
|
+
};
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
function parseResponseRules(node: XmlNode): QtiResponseRule[] {
|
|
1262
|
+
return childElements(node)
|
|
1263
|
+
.map(parseResponseRule)
|
|
1264
|
+
.filter((rule): rule is QtiResponseRule => rule !== undefined);
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
function parseResponseRule(node: XmlNode): QtiResponseRule | undefined {
|
|
1268
|
+
if (node.localName === "qti-response-condition") {
|
|
1269
|
+
return {
|
|
1270
|
+
type: "responseCondition",
|
|
1271
|
+
condition: parseResponseCondition(node),
|
|
1272
|
+
source: node.source,
|
|
1273
|
+
};
|
|
1274
|
+
}
|
|
1275
|
+
if (node.localName === "qti-set-outcome-value") return parseSetOutcomeValue(node);
|
|
1276
|
+
if (node.localName === "qti-lookup-outcome-value") return parseLookupOutcomeValue(node);
|
|
1277
|
+
if (node.localName === "qti-exit-response") {
|
|
1278
|
+
return { type: "exitResponse", source: node.source };
|
|
1279
|
+
}
|
|
1280
|
+
if (node.localName === "qti-response-processing-fragment") {
|
|
1281
|
+
return {
|
|
1282
|
+
type: "responseProcessingFragment",
|
|
1283
|
+
rules: parseResponseRules(node),
|
|
1284
|
+
source: node.source,
|
|
1285
|
+
};
|
|
1286
|
+
}
|
|
1287
|
+
return undefined;
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
function parseLookupOutcomeValue(node: XmlNode): QtiLookupOutcomeValue {
|
|
1291
|
+
return {
|
|
1292
|
+
type: "lookupOutcomeValue",
|
|
1293
|
+
identifier: node.attributes.identifier ?? "",
|
|
1294
|
+
expression: parseFirstExpression(node) ?? {
|
|
1295
|
+
type: "baseValue",
|
|
1296
|
+
value: null,
|
|
1297
|
+
source: node.source,
|
|
1298
|
+
},
|
|
1299
|
+
source: node.source,
|
|
1300
|
+
};
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
function parseSetOutcomeValue(setNode: XmlNode): QtiSetOutcomeValue {
|
|
1304
|
+
return {
|
|
1305
|
+
type: "setOutcomeValue",
|
|
1306
|
+
identifier: setNode.attributes.identifier ?? "",
|
|
1307
|
+
expression: parseFirstExpression(setNode) ?? {
|
|
1308
|
+
type: "baseValue",
|
|
1309
|
+
value: null,
|
|
1310
|
+
source: setNode.source,
|
|
1311
|
+
},
|
|
1312
|
+
source: setNode.source,
|
|
1313
|
+
};
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
function parseFirstExpression(node: XmlNode): QtiProcessingExpression | undefined {
|
|
1317
|
+
for (const child of node.children) {
|
|
1318
|
+
const expression = parseExpression(child);
|
|
1319
|
+
if (expression) return expression;
|
|
1320
|
+
}
|
|
1321
|
+
return undefined;
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
function parseExpression(node: XmlNode): QtiProcessingExpression | undefined {
|
|
1325
|
+
if (node.localName === "qti-base-value") {
|
|
1326
|
+
const rawValue = textContent(node);
|
|
1327
|
+
return {
|
|
1328
|
+
type: "baseValue",
|
|
1329
|
+
value: coerceValue(rawValue, node.attributes["base-type"]),
|
|
1330
|
+
rawValue,
|
|
1331
|
+
baseType: node.attributes["base-type"],
|
|
1332
|
+
source: node.source,
|
|
1333
|
+
};
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
if (node.localName === "qti-null") {
|
|
1337
|
+
return { type: "null", source: node.source };
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
if (node.localName === "qti-is-null") {
|
|
1341
|
+
const variable = childElements(node, "qti-variable")[0];
|
|
1342
|
+
return {
|
|
1343
|
+
type: "isNull",
|
|
1344
|
+
identifier: variable?.attributes.identifier ?? "",
|
|
1345
|
+
source: node.source,
|
|
1346
|
+
};
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
if (node.localName === "qti-map-response") {
|
|
1350
|
+
return {
|
|
1351
|
+
type: "mapResponse",
|
|
1352
|
+
identifier: node.attributes.identifier ?? "",
|
|
1353
|
+
source: node.source,
|
|
1354
|
+
};
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
if (node.localName === "qti-map-response-point") {
|
|
1358
|
+
return {
|
|
1359
|
+
type: "mapResponsePoint",
|
|
1360
|
+
identifier: node.attributes.identifier ?? "",
|
|
1361
|
+
source: node.source,
|
|
1362
|
+
};
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
if (node.localName === "qti-correct") {
|
|
1366
|
+
return {
|
|
1367
|
+
type: "correct",
|
|
1368
|
+
identifier: node.attributes.identifier ?? "",
|
|
1369
|
+
source: node.source,
|
|
1370
|
+
};
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
if (node.localName === "qti-default") {
|
|
1374
|
+
return {
|
|
1375
|
+
type: "default",
|
|
1376
|
+
identifier: node.attributes.identifier ?? "",
|
|
1377
|
+
source: node.source,
|
|
1378
|
+
};
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
if (node.localName === "qti-variable") {
|
|
1382
|
+
return { type: "variable", identifier: node.attributes.identifier ?? "", source: node.source };
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
if (node.localName === "qti-random-integer") {
|
|
1386
|
+
return {
|
|
1387
|
+
type: "randomInteger",
|
|
1388
|
+
min: Number(node.attributes.min ?? 0),
|
|
1389
|
+
max: Number(node.attributes.max ?? 0),
|
|
1390
|
+
step: Number(node.attributes.step ?? 1),
|
|
1391
|
+
attributes: node.attributes,
|
|
1392
|
+
source: node.source,
|
|
1393
|
+
};
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
if (node.localName === "qti-random-float") {
|
|
1397
|
+
return {
|
|
1398
|
+
type: "randomFloat",
|
|
1399
|
+
min: Number(node.attributes.min ?? 0),
|
|
1400
|
+
max: Number(node.attributes.max ?? 0),
|
|
1401
|
+
attributes: node.attributes,
|
|
1402
|
+
source: node.source,
|
|
1403
|
+
};
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
if (node.localName === "qti-random") {
|
|
1407
|
+
const multiple = childElements(node, "qti-multiple")[0];
|
|
1408
|
+
return {
|
|
1409
|
+
type: "random",
|
|
1410
|
+
values: childElements(multiple ?? node)
|
|
1411
|
+
.map(parseExpression)
|
|
1412
|
+
.filter((expression): expression is QtiProcessingExpression => expression !== undefined),
|
|
1413
|
+
source: node.source,
|
|
1414
|
+
};
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
if (node.localName === "qti-multiple") {
|
|
1418
|
+
return {
|
|
1419
|
+
type: "multiple",
|
|
1420
|
+
expressions: childElements(node)
|
|
1421
|
+
.map(parseExpression)
|
|
1422
|
+
.filter((expression): expression is QtiProcessingExpression => expression !== undefined),
|
|
1423
|
+
source: node.source,
|
|
1424
|
+
};
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
if (node.localName === "qti-ordered") {
|
|
1428
|
+
return {
|
|
1429
|
+
type: "ordered",
|
|
1430
|
+
expressions: childElements(node)
|
|
1431
|
+
.map(parseExpression)
|
|
1432
|
+
.filter((expression): expression is QtiProcessingExpression => expression !== undefined),
|
|
1433
|
+
source: node.source,
|
|
1434
|
+
};
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
if (node.localName === "qti-index") {
|
|
1438
|
+
const expression = parseFirstExpression(node);
|
|
1439
|
+
if (expression) {
|
|
1440
|
+
return { type: "index", expression, n: node.attributes.n ?? "", source: node.source };
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
if (node.localName === "qti-container-size") {
|
|
1445
|
+
const expression = parseFirstExpression(node);
|
|
1446
|
+
if (expression) return { type: "containerSize", expression, source: node.source };
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
if (node.localName === "qti-sum") {
|
|
1450
|
+
return {
|
|
1451
|
+
type: "sum",
|
|
1452
|
+
expressions: childElements(node)
|
|
1453
|
+
.map(parseExpression)
|
|
1454
|
+
.filter((expression): expression is QtiProcessingExpression => expression !== undefined),
|
|
1455
|
+
source: node.source,
|
|
1456
|
+
};
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
if (node.localName === "qti-product") {
|
|
1460
|
+
return {
|
|
1461
|
+
type: "product",
|
|
1462
|
+
expressions: childElements(node)
|
|
1463
|
+
.map(parseExpression)
|
|
1464
|
+
.filter((expression): expression is QtiProcessingExpression => expression !== undefined),
|
|
1465
|
+
source: node.source,
|
|
1466
|
+
};
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
if (node.localName === "qti-min") {
|
|
1470
|
+
return {
|
|
1471
|
+
type: "min",
|
|
1472
|
+
expressions: childElements(node)
|
|
1473
|
+
.map(parseExpression)
|
|
1474
|
+
.filter((expression): expression is QtiProcessingExpression => expression !== undefined),
|
|
1475
|
+
source: node.source,
|
|
1476
|
+
};
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
if (node.localName === "qti-max") {
|
|
1480
|
+
return {
|
|
1481
|
+
type: "max",
|
|
1482
|
+
expressions: childElements(node)
|
|
1483
|
+
.map(parseExpression)
|
|
1484
|
+
.filter((expression): expression is QtiProcessingExpression => expression !== undefined),
|
|
1485
|
+
source: node.source,
|
|
1486
|
+
};
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
if (node.localName === "qti-subtract") {
|
|
1490
|
+
const expressions = childElements(node)
|
|
1491
|
+
.map(parseExpression)
|
|
1492
|
+
.filter((expression): expression is QtiProcessingExpression => expression !== undefined);
|
|
1493
|
+
const [left, right] = expressions;
|
|
1494
|
+
if (left && right) return { type: "subtract", left, right, source: node.source };
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
if (node.localName === "qti-divide") {
|
|
1498
|
+
const expressions = childElements(node)
|
|
1499
|
+
.map(parseExpression)
|
|
1500
|
+
.filter((expression): expression is QtiProcessingExpression => expression !== undefined);
|
|
1501
|
+
const [left, right] = expressions;
|
|
1502
|
+
if (left && right) return { type: "divide", left, right, source: node.source };
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
if (node.localName === "qti-power") {
|
|
1506
|
+
const expressions = childElements(node)
|
|
1507
|
+
.map(parseExpression)
|
|
1508
|
+
.filter((expression): expression is QtiProcessingExpression => expression !== undefined);
|
|
1509
|
+
const [left, right] = expressions;
|
|
1510
|
+
if (left && right) return { type: "power", left, right, source: node.source };
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
if (node.localName === "qti-integer-divide") {
|
|
1514
|
+
const expressions = childElements(node)
|
|
1515
|
+
.map(parseExpression)
|
|
1516
|
+
.filter((expression): expression is QtiProcessingExpression => expression !== undefined);
|
|
1517
|
+
const [left, right] = expressions;
|
|
1518
|
+
if (left && right) return { type: "integerDivide", left, right, source: node.source };
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
if (node.localName === "qti-integer-modulus") {
|
|
1522
|
+
const expressions = childElements(node)
|
|
1523
|
+
.map(parseExpression)
|
|
1524
|
+
.filter((expression): expression is QtiProcessingExpression => expression !== undefined);
|
|
1525
|
+
const [left, right] = expressions;
|
|
1526
|
+
if (left && right) return { type: "integerModulus", left, right, source: node.source };
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
if (node.localName === "qti-round") {
|
|
1530
|
+
const expression = parseFirstExpression(node);
|
|
1531
|
+
if (expression) return { type: "round", expression, source: node.source };
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
if (node.localName === "qti-round-to") {
|
|
1535
|
+
const expression = parseFirstExpression(node);
|
|
1536
|
+
const roundingMode = node.attributes["rounding-mode"];
|
|
1537
|
+
const figures = Number(node.attributes.figures ?? 0);
|
|
1538
|
+
if (
|
|
1539
|
+
expression &&
|
|
1540
|
+
(roundingMode === "decimalPlaces" || roundingMode === "significantFigures") &&
|
|
1541
|
+
Number.isInteger(figures) &&
|
|
1542
|
+
(roundingMode === "decimalPlaces" ? figures >= 0 : figures > 0)
|
|
1543
|
+
) {
|
|
1544
|
+
return { type: "roundTo", expression, roundingMode, figures, source: node.source };
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
if (node.localName === "qti-truncate") {
|
|
1549
|
+
const expression = parseFirstExpression(node);
|
|
1550
|
+
if (expression) return { type: "truncate", expression, source: node.source };
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
if (node.localName === "qti-integer-to-float") {
|
|
1554
|
+
const expression = parseFirstExpression(node);
|
|
1555
|
+
if (expression) return { type: "integerToFloat", expression, source: node.source };
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
if (node.localName === "qti-and") {
|
|
1559
|
+
return {
|
|
1560
|
+
type: "and",
|
|
1561
|
+
expressions: childElements(node)
|
|
1562
|
+
.map(parseExpression)
|
|
1563
|
+
.filter((expression): expression is QtiProcessingExpression => expression !== undefined),
|
|
1564
|
+
source: node.source,
|
|
1565
|
+
};
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
if (node.localName === "qti-any-n") {
|
|
1569
|
+
return {
|
|
1570
|
+
type: "anyN",
|
|
1571
|
+
min: node.attributes.min ?? "",
|
|
1572
|
+
max: node.attributes.max ?? "",
|
|
1573
|
+
expressions: childElements(node)
|
|
1574
|
+
.map(parseExpression)
|
|
1575
|
+
.filter((expression): expression is QtiProcessingExpression => expression !== undefined),
|
|
1576
|
+
source: node.source,
|
|
1577
|
+
};
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
if (node.localName === "qti-or") {
|
|
1581
|
+
return {
|
|
1582
|
+
type: "or",
|
|
1583
|
+
expressions: childElements(node)
|
|
1584
|
+
.map(parseExpression)
|
|
1585
|
+
.filter((expression): expression is QtiProcessingExpression => expression !== undefined),
|
|
1586
|
+
source: node.source,
|
|
1587
|
+
};
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
if (node.localName === "qti-not") {
|
|
1591
|
+
const expression = parseFirstExpression(node);
|
|
1592
|
+
if (expression) return { type: "not", expression, source: node.source };
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
if (node.localName === "qti-equal") {
|
|
1596
|
+
const [left, right] = childElements(node)
|
|
1597
|
+
.map(parseExpression)
|
|
1598
|
+
.filter((expression): expression is QtiProcessingExpression => expression !== undefined);
|
|
1599
|
+
if (left && right) return { type: "equal", left, right, source: node.source };
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
if (node.localName === "qti-equal-rounded") {
|
|
1603
|
+
const [left, right] = childElements(node)
|
|
1604
|
+
.map(parseExpression)
|
|
1605
|
+
.filter((expression): expression is QtiProcessingExpression => expression !== undefined);
|
|
1606
|
+
const roundingMode = node.attributes["rounding-mode"] ?? "";
|
|
1607
|
+
const figures = Number(node.attributes.figures ?? 0);
|
|
1608
|
+
if (left && right) {
|
|
1609
|
+
return { type: "equalRounded", left, right, roundingMode, figures, source: node.source };
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
const numericCompareOperator = numericCompareOperatorFor(node.localName);
|
|
1614
|
+
if (numericCompareOperator) {
|
|
1615
|
+
const [left, right] = childElements(node)
|
|
1616
|
+
.map(parseExpression)
|
|
1617
|
+
.filter((expression): expression is QtiProcessingExpression => expression !== undefined);
|
|
1618
|
+
if (left && right) {
|
|
1619
|
+
return {
|
|
1620
|
+
type: "numericCompare",
|
|
1621
|
+
operator: numericCompareOperator,
|
|
1622
|
+
left,
|
|
1623
|
+
right,
|
|
1624
|
+
source: node.source,
|
|
1625
|
+
};
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
const durationCompareOperator = durationCompareOperatorFor(node.localName);
|
|
1630
|
+
if (durationCompareOperator) {
|
|
1631
|
+
const [left, right] = childElements(node)
|
|
1632
|
+
.map(parseExpression)
|
|
1633
|
+
.filter((expression): expression is QtiProcessingExpression => expression !== undefined);
|
|
1634
|
+
if (left && right) {
|
|
1635
|
+
return {
|
|
1636
|
+
type: "durationCompare",
|
|
1637
|
+
operator: durationCompareOperator,
|
|
1638
|
+
left,
|
|
1639
|
+
right,
|
|
1640
|
+
source: node.source,
|
|
1641
|
+
};
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
if (node.localName === "qti-string-match") {
|
|
1646
|
+
const [left, right] = childElements(node)
|
|
1647
|
+
.map(parseExpression)
|
|
1648
|
+
.filter((expression): expression is QtiProcessingExpression => expression !== undefined);
|
|
1649
|
+
if (left && right) {
|
|
1650
|
+
return {
|
|
1651
|
+
type: "stringMatch",
|
|
1652
|
+
left,
|
|
1653
|
+
right,
|
|
1654
|
+
caseSensitive: node.attributes["case-sensitive"] !== "false",
|
|
1655
|
+
substring: node.attributes.substring === "true",
|
|
1656
|
+
source: node.source,
|
|
1657
|
+
};
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
if (node.localName === "qti-substring") {
|
|
1662
|
+
const [left, right] = childElements(node)
|
|
1663
|
+
.map(parseExpression)
|
|
1664
|
+
.filter((expression): expression is QtiProcessingExpression => expression !== undefined);
|
|
1665
|
+
if (left && right) {
|
|
1666
|
+
return {
|
|
1667
|
+
type: "substring",
|
|
1668
|
+
left,
|
|
1669
|
+
right,
|
|
1670
|
+
caseSensitive: node.attributes["case-sensitive"] !== "false",
|
|
1671
|
+
source: node.source,
|
|
1672
|
+
};
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
if (node.localName === "qti-pattern-match") {
|
|
1677
|
+
const expression = parseFirstExpression(node);
|
|
1678
|
+
if (expression) {
|
|
1679
|
+
return {
|
|
1680
|
+
type: "patternMatch",
|
|
1681
|
+
expression,
|
|
1682
|
+
pattern: node.attributes.pattern ?? "",
|
|
1683
|
+
source: node.source,
|
|
1684
|
+
};
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
if (node.localName === "qti-field-value") {
|
|
1689
|
+
const expression = parseFirstExpression(node);
|
|
1690
|
+
if (expression) {
|
|
1691
|
+
return {
|
|
1692
|
+
type: "fieldValue",
|
|
1693
|
+
fieldIdentifier: node.attributes["field-identifier"] ?? "",
|
|
1694
|
+
expression,
|
|
1695
|
+
source: node.source,
|
|
1696
|
+
};
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
if (node.localName === "qti-member") {
|
|
1701
|
+
const [value, collection] = childElements(node)
|
|
1702
|
+
.map(parseExpression)
|
|
1703
|
+
.filter((expression): expression is QtiProcessingExpression => expression !== undefined);
|
|
1704
|
+
if (value && collection) return { type: "member", value, collection, source: node.source };
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
if (node.localName === "qti-delete") {
|
|
1708
|
+
const [value, collection] = childElements(node)
|
|
1709
|
+
.map(parseExpression)
|
|
1710
|
+
.filter((expression): expression is QtiProcessingExpression => expression !== undefined);
|
|
1711
|
+
if (value && collection) return { type: "delete", value, collection, source: node.source };
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
if (node.localName === "qti-contains") {
|
|
1715
|
+
const [collection, values] = childElements(node)
|
|
1716
|
+
.map(parseExpression)
|
|
1717
|
+
.filter((expression): expression is QtiProcessingExpression => expression !== undefined);
|
|
1718
|
+
if (collection && values) return { type: "contains", collection, values, source: node.source };
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
if (node.localName === "qti-gcd" || node.localName === "qti-lcm") {
|
|
1722
|
+
return {
|
|
1723
|
+
type: node.localName === "qti-gcd" ? "gcd" : "lcm",
|
|
1724
|
+
expressions: childElements(node)
|
|
1725
|
+
.map(parseExpression)
|
|
1726
|
+
.filter((expression): expression is QtiProcessingExpression => expression !== undefined),
|
|
1727
|
+
source: node.source,
|
|
1728
|
+
};
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
if (node.localName === "qti-inside") {
|
|
1732
|
+
const expression = parseFirstExpression(node);
|
|
1733
|
+
if (expression) {
|
|
1734
|
+
return {
|
|
1735
|
+
type: "inside",
|
|
1736
|
+
expression,
|
|
1737
|
+
shape: parseShape(node.attributes.shape),
|
|
1738
|
+
coords: parseCoords(node.attributes.coords),
|
|
1739
|
+
attributes: node.attributes,
|
|
1740
|
+
source: node.source,
|
|
1741
|
+
};
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
if (node.localName === "qti-math-constant") {
|
|
1746
|
+
return { type: "mathConstant", name: node.attributes.name ?? "", source: node.source };
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
if (node.localName === "qti-math-operator") {
|
|
1750
|
+
return {
|
|
1751
|
+
type: "mathOperator",
|
|
1752
|
+
name: node.attributes.name ?? "",
|
|
1753
|
+
expressions: childElements(node)
|
|
1754
|
+
.map(parseExpression)
|
|
1755
|
+
.filter((expression): expression is QtiProcessingExpression => expression !== undefined),
|
|
1756
|
+
source: node.source,
|
|
1757
|
+
};
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
if (node.localName === "qti-repeat") {
|
|
1761
|
+
return {
|
|
1762
|
+
type: "repeat",
|
|
1763
|
+
numberRepeats: node.attributes["number-repeats"] ?? "",
|
|
1764
|
+
expressions: childElements(node)
|
|
1765
|
+
.map(parseExpression)
|
|
1766
|
+
.filter((expression): expression is QtiProcessingExpression => expression !== undefined),
|
|
1767
|
+
source: node.source,
|
|
1768
|
+
};
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
if (node.localName === "qti-stats-operator") {
|
|
1772
|
+
const expression = parseFirstExpression(node);
|
|
1773
|
+
if (expression) {
|
|
1774
|
+
return {
|
|
1775
|
+
type: "statsOperator",
|
|
1776
|
+
name: node.attributes.name ?? "",
|
|
1777
|
+
expression,
|
|
1778
|
+
source: node.source,
|
|
1779
|
+
};
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
if (node.localName === "qti-custom-operator") {
|
|
1784
|
+
return {
|
|
1785
|
+
type: "customOperator",
|
|
1786
|
+
definition: node.attributes.definition,
|
|
1787
|
+
className: node.attributes.class,
|
|
1788
|
+
attributes: node.attributes,
|
|
1789
|
+
expressions: childElements(node)
|
|
1790
|
+
.map(parseExpression)
|
|
1791
|
+
.filter((expression): expression is QtiProcessingExpression => expression !== undefined),
|
|
1792
|
+
source: node.source,
|
|
1793
|
+
};
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
if (node.localName === "qti-match") {
|
|
1797
|
+
const variable = childElements(node, "qti-variable")[0];
|
|
1798
|
+
const correct = childElements(node, "qti-correct")[0];
|
|
1799
|
+
if (variable && correct) {
|
|
1800
|
+
return {
|
|
1801
|
+
type: "matchCorrect",
|
|
1802
|
+
identifier: variable?.attributes.identifier ?? "",
|
|
1803
|
+
correctIdentifier: correct?.attributes.identifier ?? "",
|
|
1804
|
+
source: node.source,
|
|
1805
|
+
};
|
|
1806
|
+
}
|
|
1807
|
+
const [left, right] = childElements(node)
|
|
1808
|
+
.map(parseExpression)
|
|
1809
|
+
.filter((expression): expression is QtiProcessingExpression => expression !== undefined);
|
|
1810
|
+
if (left && right) return { type: "match", left, right, source: node.source };
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
return undefined;
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
function numericCompareOperatorFor(localName: string): "lt" | "lte" | "gt" | "gte" | undefined {
|
|
1817
|
+
if (localName === "qti-lt") return "lt";
|
|
1818
|
+
if (localName === "qti-lte") return "lte";
|
|
1819
|
+
if (localName === "qti-gt") return "gt";
|
|
1820
|
+
if (localName === "qti-gte") return "gte";
|
|
1821
|
+
return undefined;
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
function durationCompareOperatorFor(localName: string): "lt" | "gte" | undefined {
|
|
1825
|
+
if (localName === "qti-duration-lt") return "lt";
|
|
1826
|
+
if (localName === "qti-duration-gte") return "gte";
|
|
1827
|
+
return undefined;
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
function coerceValue(value: string, baseType: string | undefined): QtiScalarValue {
|
|
1831
|
+
if (baseType === "integer") return Number.parseInt(value, 10);
|
|
1832
|
+
if (baseType === "float") return Number.parseFloat(value);
|
|
1833
|
+
if (baseType === "boolean") {
|
|
1834
|
+
if (value === "true") return true;
|
|
1835
|
+
if (value === "false") return false;
|
|
1836
|
+
}
|
|
1837
|
+
return value;
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
function parseCardinality(value: string | undefined): QtiCardinality {
|
|
1841
|
+
if (value === "multiple" || value === "ordered" || value === "record") return value;
|
|
1842
|
+
return "single";
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
function normalizeValueForCardinality(value: QtiValue, cardinality: QtiCardinality): QtiValue {
|
|
1846
|
+
if (
|
|
1847
|
+
(cardinality === "multiple" || cardinality === "ordered") &&
|
|
1848
|
+
value !== null &&
|
|
1849
|
+
!Array.isArray(value) &&
|
|
1850
|
+
!isRecordValue(value)
|
|
1851
|
+
) {
|
|
1852
|
+
return [value];
|
|
1853
|
+
}
|
|
1854
|
+
return value;
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
function isRecordValue(value: QtiValue): value is QtiRecordValue {
|
|
1858
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1859
|
+
}
|