@conform-ed/qti-xml 0.0.16

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.
@@ -0,0 +1,2814 @@
1
+ import type { QtiXmlElementNode, QtiXmlNode } from "./parse-xml";
2
+ import type { QtiSchemaSelectionKey, QtiVersion } from "./types";
3
+
4
+ const qtiV22DomainContentNames = new Set([
5
+ "associateInteraction",
6
+ "choiceInteraction",
7
+ "customInteraction",
8
+ "endAttemptInteraction",
9
+ "extendedTextInteraction",
10
+ "gap",
11
+ "gapImg",
12
+ "gapMatchInteraction",
13
+ "gapText",
14
+ "graphicAssociateInteraction",
15
+ "graphicGapMatchInteraction",
16
+ "graphicOrderInteraction",
17
+ "hotspotChoice",
18
+ "hotspotInteraction",
19
+ "hotText",
20
+ "hotTextInteraction",
21
+ "inlineChoice",
22
+ "inlineChoiceInteraction",
23
+ "matchInteraction",
24
+ "mediaInteraction",
25
+ "orderInteraction",
26
+ "positionObjectInteraction",
27
+ "prompt",
28
+ "selectPointInteraction",
29
+ "simpleAssociableChoice",
30
+ "simpleChoice",
31
+ "sliderInteraction",
32
+ "textEntryInteraction",
33
+ "uploadInteraction",
34
+ ]);
35
+
36
+ // Spelled exactly as the QTI 3 XSD (and the official corpus) spell them — notably
37
+ // `qti-hottext`, not `qti-hot-text`.
38
+ const qtiV30DomainContentNames = new Set([
39
+ "qti-associable-hotspot",
40
+ "qti-associate-interaction",
41
+ "qti-choice-interaction",
42
+ "qti-custom-interaction",
43
+ "qti-drawing-interaction",
44
+ "qti-end-attempt-interaction",
45
+ "qti-extended-text-interaction",
46
+ "qti-feedback-block",
47
+ "qti-feedback-inline",
48
+ "qti-gap",
49
+ "qti-gap-img",
50
+ "qti-gap-match-interaction",
51
+ "qti-gap-text",
52
+ "qti-graphic-associate-interaction",
53
+ "qti-graphic-gap-match-interaction",
54
+ "qti-graphic-order-interaction",
55
+ "qti-hotspot-choice",
56
+ "qti-hotspot-interaction",
57
+ "qti-hottext",
58
+ "qti-hottext-interaction",
59
+ "qti-include",
60
+ "qti-inline-choice",
61
+ "qti-inline-choice-interaction",
62
+ "qti-match-interaction",
63
+ "qti-media-interaction",
64
+ "qti-order-interaction",
65
+ "qti-portable-custom-interaction",
66
+ "qti-position-object-interaction",
67
+ "qti-position-object-stage",
68
+ "qti-printed-variable",
69
+ "qti-prompt",
70
+ "qti-rubric-block",
71
+ "qti-select-point-interaction",
72
+ "qti-simple-associable-choice",
73
+ "qti-simple-choice",
74
+ "qti-simple-match-set",
75
+ "qti-slider-interaction",
76
+ "qti-template-block",
77
+ "qti-template-inline",
78
+ "qti-text-entry-interaction",
79
+ "qti-upload-interaction",
80
+ ]);
81
+
82
+ function normalizeTextValue(value: string): string | undefined {
83
+ const normalized = value.replace(/\s+/gu, " ").trim();
84
+ return normalized.length > 0 ? normalized : undefined;
85
+ }
86
+
87
+ function attributeBoolean(attributes: Record<string, string>, ...names: string[]): boolean | undefined {
88
+ for (const name of names) {
89
+ const value = attributes[name];
90
+ if (value === "true") {
91
+ return true;
92
+ }
93
+ if (value === "false") {
94
+ return false;
95
+ }
96
+ }
97
+ return undefined;
98
+ }
99
+
100
+ function attributeNumber(attributes: Record<string, string>, ...names: string[]): number | undefined {
101
+ for (const name of names) {
102
+ const value = attributes[name];
103
+ if (value === undefined) {
104
+ continue;
105
+ }
106
+ const parsed = Number(value);
107
+ if (!Number.isNaN(parsed)) {
108
+ return parsed;
109
+ }
110
+ }
111
+ return undefined;
112
+ }
113
+
114
+ function requireAttribute(element: QtiXmlElementNode, ...names: string[]): string {
115
+ for (const name of names) {
116
+ const value = element.attributes[name];
117
+ if (typeof value === "string" && value.length > 0) {
118
+ return value;
119
+ }
120
+ }
121
+
122
+ throw new Error(`Missing required attribute on <${element.name}>: ${names.join(", ")}`);
123
+ }
124
+
125
+ function childElements(element: QtiXmlElementNode, localName?: string): QtiXmlElementNode[] {
126
+ return element.children.filter(
127
+ (child): child is QtiXmlElementNode =>
128
+ child.type === "element" && (localName ? child.localName === localName : true),
129
+ );
130
+ }
131
+
132
+ function firstChildElement(element: QtiXmlElementNode, localName: string): QtiXmlElementNode | undefined {
133
+ return childElements(element, localName)[0];
134
+ }
135
+
136
+ function textContent(element: QtiXmlElementNode): string | undefined {
137
+ const parts: string[] = [];
138
+
139
+ for (const child of element.children) {
140
+ if (child.type === "text") {
141
+ const normalized = normalizeTextValue(child.value);
142
+ if (normalized) {
143
+ parts.push(normalized);
144
+ }
145
+ continue;
146
+ }
147
+
148
+ const nested = textContent(child);
149
+ if (nested) {
150
+ parts.push(nested);
151
+ }
152
+ }
153
+
154
+ return parts.length ? parts.join(" ") : undefined;
155
+ }
156
+
157
+ function mapValueList(element: QtiXmlElementNode): Array<{ value: string }> {
158
+ return childElements(element, "value").flatMap((valueElement) => {
159
+ const value = textContent(valueElement);
160
+ return value !== undefined ? [{ value }] : [];
161
+ });
162
+ }
163
+
164
+ function mapV2ResponseDeclaration(element: QtiXmlElementNode) {
165
+ const correctResponseElement = firstChildElement(element, "correctResponse");
166
+ const defaultValueElement = firstChildElement(element, "defaultValue");
167
+
168
+ return {
169
+ identifier: requireAttribute(element, "identifier"),
170
+ cardinality: requireAttribute(element, "cardinality"),
171
+ baseType: element.attributes["baseType"],
172
+ ...(correctResponseElement
173
+ ? {
174
+ correctResponse: {
175
+ values: mapValueList(correctResponseElement),
176
+ },
177
+ }
178
+ : {}),
179
+ ...(defaultValueElement
180
+ ? {
181
+ defaultValue: {
182
+ values: mapValueList(defaultValueElement),
183
+ },
184
+ }
185
+ : {}),
186
+ };
187
+ }
188
+
189
+ function mapV2OutcomeDeclaration(element: QtiXmlElementNode) {
190
+ const defaultValueElement = firstChildElement(element, "defaultValue");
191
+
192
+ return {
193
+ identifier: requireAttribute(element, "identifier"),
194
+ cardinality: requireAttribute(element, "cardinality"),
195
+ baseType: element.attributes["baseType"],
196
+ ...(defaultValueElement
197
+ ? {
198
+ defaultValue: {
199
+ values: mapValueList(defaultValueElement),
200
+ },
201
+ }
202
+ : {}),
203
+ ...(element.attributes["interpretation"] ? { interpretation: element.attributes["interpretation"] } : {}),
204
+ ...(element.attributes["longInterpretation"]
205
+ ? { longInterpretation: element.attributes["longInterpretation"] }
206
+ : {}),
207
+ ...(attributeNumber(element.attributes, "normalMaximum") !== undefined
208
+ ? { normalMaximum: attributeNumber(element.attributes, "normalMaximum") }
209
+ : {}),
210
+ ...(attributeNumber(element.attributes, "normalMinimum") !== undefined
211
+ ? { normalMinimum: attributeNumber(element.attributes, "normalMinimum") }
212
+ : {}),
213
+ ...(attributeNumber(element.attributes, "masteryValue") !== undefined
214
+ ? { masteryValue: attributeNumber(element.attributes, "masteryValue") }
215
+ : {}),
216
+ };
217
+ }
218
+
219
+ function mapV2ContentNodes(nodes: QtiXmlNode[]): unknown[] {
220
+ const content: unknown[] = [];
221
+
222
+ for (const node of nodes) {
223
+ if (node.type === "text") {
224
+ const value = normalizeTextValue(node.value);
225
+ if (value) {
226
+ content.push({ kind: "text", value });
227
+ }
228
+ continue;
229
+ }
230
+
231
+ if (qtiV22DomainContentNames.has(node.localName)) {
232
+ switch (node.localName) {
233
+ case "prompt":
234
+ content.push({
235
+ kind: "prompt",
236
+ children: mapV2ContentNodes(node.children),
237
+ });
238
+ break;
239
+ case "simpleChoice":
240
+ content.push({
241
+ kind: "simpleChoice",
242
+ identifier: requireAttribute(node, "identifier"),
243
+ ...(attributeBoolean(node.attributes, "fixed") !== undefined
244
+ ? { fixed: attributeBoolean(node.attributes, "fixed") }
245
+ : {}),
246
+ ...(node.attributes["showHide"] ? { showHide: node.attributes["showHide"] } : {}),
247
+ ...(mapV2ContentNodes(node.children).length ? { children: mapV2ContentNodes(node.children) } : {}),
248
+ });
249
+ break;
250
+ case "choiceInteraction":
251
+ case "orderInteraction": {
252
+ const prompt = firstChildElement(node, "prompt");
253
+ const simpleChoices = childElements(node, "simpleChoice").map((choiceElement) => {
254
+ const [mapped] = mapV2ContentNodes([choiceElement]);
255
+ return mapped;
256
+ });
257
+
258
+ content.push({
259
+ kind: node.localName,
260
+ responseIdentifier: requireAttribute(node, "responseIdentifier"),
261
+ ...(attributeBoolean(node.attributes, "shuffle") !== undefined
262
+ ? { shuffle: attributeBoolean(node.attributes, "shuffle") }
263
+ : {}),
264
+ ...(attributeNumber(node.attributes, "maxChoices") !== undefined
265
+ ? { maxChoices: attributeNumber(node.attributes, "maxChoices") }
266
+ : {}),
267
+ ...(attributeNumber(node.attributes, "minChoices") !== undefined
268
+ ? { minChoices: attributeNumber(node.attributes, "minChoices") }
269
+ : {}),
270
+ ...(prompt ? { prompt: mapV2ContentNodes([prompt])[0] } : {}),
271
+ simpleChoices,
272
+ });
273
+ break;
274
+ }
275
+ default:
276
+ throw new Error(`Unsupported QTI 2.2 content element <${node.localName}> in normalization.`);
277
+ }
278
+
279
+ continue;
280
+ }
281
+
282
+ const children = mapV2ContentNodes(node.children);
283
+ content.push({
284
+ kind: node.localName,
285
+ ...(Object.keys(node.attributes).length ? { attributes: node.attributes } : {}),
286
+ ...(children.length ? { children } : {}),
287
+ });
288
+ }
289
+
290
+ return content;
291
+ }
292
+
293
+ /** Split a whitespace-separated attribute value into a contracts string list. */
294
+ function attributeList(value: string | undefined): string[] | undefined {
295
+ const entries = value?.split(/\s+/u).filter(Boolean);
296
+ return entries?.length ? entries : undefined;
297
+ }
298
+
299
+ function mapV3ValueList(element: QtiXmlElementNode): Array<Record<string, unknown>> {
300
+ return childElements(element, "qti-value").map((valueElement) => ({
301
+ value: textContent(valueElement) ?? "",
302
+ ...(valueElement.attributes["base-type"] ? { baseType: valueElement.attributes["base-type"] } : {}),
303
+ ...(valueElement.attributes["field-identifier"]
304
+ ? { fieldIdentifier: valueElement.attributes["field-identifier"] }
305
+ : {}),
306
+ }));
307
+ }
308
+
309
+ function mapV3MappingBounds(element: QtiXmlElementNode) {
310
+ return {
311
+ ...(attributeNumber(element.attributes, "lower-bound") !== undefined
312
+ ? { lowerBound: attributeNumber(element.attributes, "lower-bound") }
313
+ : {}),
314
+ ...(attributeNumber(element.attributes, "upper-bound") !== undefined
315
+ ? { upperBound: attributeNumber(element.attributes, "upper-bound") }
316
+ : {}),
317
+ ...(attributeNumber(element.attributes, "default-value") !== undefined
318
+ ? { defaultValue: attributeNumber(element.attributes, "default-value") }
319
+ : {}),
320
+ };
321
+ }
322
+
323
+ function mapV3Mapping(element: QtiXmlElementNode) {
324
+ return {
325
+ ...mapV3MappingBounds(element),
326
+ mapEntries: childElements(element, "qti-map-entry").map((entry) => ({
327
+ mapKey: requireAttribute(entry, "map-key"),
328
+ mappedValue: attributeNumber(entry.attributes, "mapped-value") ?? 0,
329
+ ...(attributeBoolean(entry.attributes, "case-sensitive") !== undefined
330
+ ? { caseSensitive: attributeBoolean(entry.attributes, "case-sensitive") }
331
+ : {}),
332
+ })),
333
+ };
334
+ }
335
+
336
+ function mapV3AreaMapping(element: QtiXmlElementNode) {
337
+ return {
338
+ ...mapV3MappingBounds(element),
339
+ areaMapEntries: childElements(element, "qti-area-map-entry").map((entry) => ({
340
+ shape: requireAttribute(entry, "shape"),
341
+ coords: requireAttribute(entry, "coords"),
342
+ mappedValue: attributeNumber(entry.attributes, "mapped-value") ?? 0,
343
+ })),
344
+ };
345
+ }
346
+
347
+ function mapV3MatchTable(element: QtiXmlElementNode) {
348
+ return {
349
+ ...(element.attributes["default-value"] ? { defaultValue: element.attributes["default-value"] } : {}),
350
+ matchTableEntries: childElements(element, "qti-match-table-entry").map((entry) => ({
351
+ sourceValue: attributeNumber(entry.attributes, "source-value") ?? 0,
352
+ targetValue: requireAttribute(entry, "target-value"),
353
+ })),
354
+ };
355
+ }
356
+
357
+ function mapV3InterpolationTable(element: QtiXmlElementNode) {
358
+ return {
359
+ ...(element.attributes["default-value"] ? { defaultValue: element.attributes["default-value"] } : {}),
360
+ interpolationTableEntries: childElements(element, "qti-interpolation-table-entry").map((entry) => ({
361
+ sourceValue: attributeNumber(entry.attributes, "source-value") ?? 0,
362
+ targetValue: requireAttribute(entry, "target-value"),
363
+ ...(attributeBoolean(entry.attributes, "include-boundary") !== undefined
364
+ ? { includeBoundary: attributeBoolean(entry.attributes, "include-boundary") }
365
+ : {}),
366
+ })),
367
+ };
368
+ }
369
+
370
+ function mapV3ResponseDeclaration(element: QtiXmlElementNode) {
371
+ const correctResponseElement = firstChildElement(element, "qti-correct-response");
372
+ const defaultValueElement = firstChildElement(element, "qti-default-value");
373
+ const mappingElement = firstChildElement(element, "qti-mapping");
374
+ const areaMappingElement = firstChildElement(element, "qti-area-mapping");
375
+
376
+ return {
377
+ identifier: requireAttribute(element, "identifier"),
378
+ cardinality: requireAttribute(element, "cardinality"),
379
+ baseType: element.attributes["base-type"],
380
+ ...(defaultValueElement
381
+ ? {
382
+ defaultValue: {
383
+ values: mapV3ValueList(defaultValueElement),
384
+ },
385
+ }
386
+ : {}),
387
+ ...(correctResponseElement
388
+ ? {
389
+ correctResponse: {
390
+ values: mapV3ValueList(correctResponseElement),
391
+ },
392
+ }
393
+ : {}),
394
+ ...(mappingElement ? { mapping: mapV3Mapping(mappingElement) } : {}),
395
+ ...(areaMappingElement ? { areaMapping: mapV3AreaMapping(areaMappingElement) } : {}),
396
+ };
397
+ }
398
+
399
+ function mapV3TemplateDeclaration(element: QtiXmlElementNode) {
400
+ const defaultValueElement = firstChildElement(element, "qti-default-value");
401
+
402
+ return {
403
+ identifier: requireAttribute(element, "identifier"),
404
+ cardinality: requireAttribute(element, "cardinality"),
405
+ baseType: element.attributes["base-type"],
406
+ ...(defaultValueElement ? { defaultValue: { values: mapV3ValueList(defaultValueElement) } } : {}),
407
+ ...(attributeBoolean(element.attributes, "param-variable") !== undefined
408
+ ? { paramVariable: attributeBoolean(element.attributes, "param-variable") }
409
+ : {}),
410
+ ...(attributeBoolean(element.attributes, "math-variable") !== undefined
411
+ ? { mathVariable: attributeBoolean(element.attributes, "math-variable") }
412
+ : {}),
413
+ };
414
+ }
415
+
416
+ function mapV3ContextDeclaration(element: QtiXmlElementNode) {
417
+ const defaultValueElement = firstChildElement(element, "qti-default-value");
418
+
419
+ return {
420
+ identifier: requireAttribute(element, "identifier"),
421
+ cardinality: requireAttribute(element, "cardinality"),
422
+ baseType: element.attributes["base-type"],
423
+ ...(defaultValueElement ? { defaultValue: { values: mapV3ValueList(defaultValueElement) } } : {}),
424
+ };
425
+ }
426
+
427
+ function mapV3StyleSheet(element: QtiXmlElementNode) {
428
+ return {
429
+ href: requireAttribute(element, "href"),
430
+ type: requireAttribute(element, "type"),
431
+ ...(element.attributes["media"] ? { media: element.attributes["media"] } : {}),
432
+ ...(element.attributes["title"] ? { title: element.attributes["title"] } : {}),
433
+ };
434
+ }
435
+
436
+ function mapV3OutcomeDeclaration(element: QtiXmlElementNode) {
437
+ const defaultValueElement = firstChildElement(element, "qti-default-value");
438
+ const matchTableElement = firstChildElement(element, "qti-match-table");
439
+ const interpolationTableElement = firstChildElement(element, "qti-interpolation-table");
440
+
441
+ return {
442
+ identifier: requireAttribute(element, "identifier"),
443
+ cardinality: requireAttribute(element, "cardinality"),
444
+ baseType: element.attributes["base-type"],
445
+ ...(defaultValueElement
446
+ ? {
447
+ defaultValue: {
448
+ values: mapV3ValueList(defaultValueElement),
449
+ },
450
+ }
451
+ : {}),
452
+ ...(matchTableElement ? { matchTable: mapV3MatchTable(matchTableElement) } : {}),
453
+ ...(interpolationTableElement ? { interpolationTable: mapV3InterpolationTable(interpolationTableElement) } : {}),
454
+ ...(attributeList(element.attributes["view"]) ? { view: attributeList(element.attributes["view"]) } : {}),
455
+ ...(element.attributes["external-scored"] ? { externalScored: element.attributes["external-scored"] } : {}),
456
+ ...(element.attributes["interpretation"] ? { interpretation: element.attributes["interpretation"] } : {}),
457
+ ...(element.attributes["long-interpretation"]
458
+ ? { longInterpretation: element.attributes["long-interpretation"] }
459
+ : {}),
460
+ ...(attributeNumber(element.attributes, "normal-maximum") !== undefined
461
+ ? { normalMaximum: attributeNumber(element.attributes, "normal-maximum") }
462
+ : {}),
463
+ ...(attributeNumber(element.attributes, "normal-minimum") !== undefined
464
+ ? { normalMinimum: attributeNumber(element.attributes, "normal-minimum") }
465
+ : {}),
466
+ ...(attributeNumber(element.attributes, "mastery-value") !== undefined
467
+ ? { masteryValue: attributeNumber(element.attributes, "mastery-value") }
468
+ : {}),
469
+ };
470
+ }
471
+
472
+ function mapV3XmlNode(element: QtiXmlElementNode): unknown {
473
+ const children = mapV3ContentFragments(element.children);
474
+ const textValue = textContent(element);
475
+
476
+ return {
477
+ kind: "xml",
478
+ ...(element.namespaceUri ? { namespace: element.namespaceUri } : {}),
479
+ name: element.localName,
480
+ ...(Object.keys(element.attributes).length ? { attributes: element.attributes } : {}),
481
+ ...(children.length ? { children } : {}),
482
+ ...(children.length === 0 && textValue ? { value: textValue } : {}),
483
+ };
484
+ }
485
+
486
+ function requireNumberAttribute(element: QtiXmlElementNode, name: string): number {
487
+ const value = attributeNumber(element.attributes, name);
488
+ if (value === undefined) {
489
+ throw new Error(`Missing required numeric attribute on <${element.localName}>: ${name}`);
490
+ }
491
+ return value;
492
+ }
493
+
494
+ function optionalString(attributes: Record<string, string>, name: string, key: string): Record<string, string> {
495
+ const value = attributes[name];
496
+ return value !== undefined && value !== "" ? { [key]: value } : {};
497
+ }
498
+
499
+ function optionalNumber(attributes: Record<string, string>, name: string, key: string): Record<string, number> {
500
+ const value = attributeNumber(attributes, name);
501
+ return value !== undefined ? { [key]: value } : {};
502
+ }
503
+
504
+ function optionalBoolean(attributes: Record<string, string>, name: string, key: string): Record<string, boolean> {
505
+ const value = attributeBoolean(attributes, name);
506
+ return value !== undefined ? { [key]: value } : {};
507
+ }
508
+
509
+ function contentOf(node: QtiXmlElementNode): { content?: unknown[] } {
510
+ // QTI 3 block containers (feedback/template/rubric blocks, modal feedback) wrap
511
+ // their flow content in a <qti-content-body>; the normalized node carries the
512
+ // content directly. A sibling <qti-catalog-info> is dormant alternative content
513
+ // (§5.29) mapped by catalogInfoOf, never part of the flow content.
514
+ const contentBody = firstChildElement(node, "qti-content-body");
515
+ const children = contentBody
516
+ ? contentBody.children
517
+ : node.children.filter((child) => child.type !== "element" || child.localName !== "qti-catalog-info");
518
+ const content = mapV3ContentFragments(children);
519
+ return content.length ? { content } : {};
520
+ }
521
+
522
+ // ---------- Companion materials (§2.13.1 "content props") ----------
523
+
524
+ function mapV3ItemFileInfo(element: QtiXmlElementNode) {
525
+ const fileHref = firstChildElement(element, "qti-file-href");
526
+ const resourceIcon = firstChildElement(element, "qti-resource-icon");
527
+
528
+ return {
529
+ ...optionalString(element.attributes, "mime-type", "mimeType"),
530
+ ...optionalString(element.attributes, "label", "label"),
531
+ fileHref: (fileHref ? textContent(fileHref) : undefined) ?? "",
532
+ ...(resourceIcon ? { resourceIcon: textContent(resourceIcon) ?? "" } : {}),
533
+ };
534
+ }
535
+
536
+ /** A measured increment: decimal text content plus its required unit attribute. */
537
+ function mapV3MeasurementValue(element: QtiXmlElementNode) {
538
+ return {
539
+ value: Number(textContent(element) ?? "0"),
540
+ unit: requireAttribute(element, "unit"),
541
+ };
542
+ }
543
+
544
+ function mapV3CompanionRuleSystem(element: QtiXmlElementNode) {
545
+ const minimumLength = firstChildElement(element, "qti-minimum-length");
546
+ const minorIncrement = firstChildElement(element, "qti-minor-increment");
547
+ const majorIncrement = firstChildElement(element, "qti-major-increment");
548
+ if (!majorIncrement) {
549
+ throw new Error(`<${element.localName}> must contain <qti-major-increment>.`);
550
+ }
551
+
552
+ return {
553
+ minimumLength: Number((minimumLength ? textContent(minimumLength) : undefined) ?? "0"),
554
+ ...(minorIncrement ? { minorIncrement: mapV3MeasurementValue(minorIncrement) } : {}),
555
+ majorIncrement: mapV3MeasurementValue(majorIncrement),
556
+ };
557
+ }
558
+
559
+ function mapV3ProtractorIncrement(element: QtiXmlElementNode) {
560
+ const minorIncrement = firstChildElement(element, "qti-minor-increment");
561
+ const majorIncrement = firstChildElement(element, "qti-major-increment");
562
+ if (!majorIncrement) {
563
+ throw new Error(`<${element.localName}> must contain <qti-major-increment>.`);
564
+ }
565
+
566
+ return {
567
+ ...(minorIncrement ? { minorIncrement: mapV3MeasurementValue(minorIncrement) } : {}),
568
+ majorIncrement: mapV3MeasurementValue(majorIncrement),
569
+ };
570
+ }
571
+
572
+ function mapV3CompanionMaterialsInfo(element: QtiXmlElementNode) {
573
+ const calculators = childElements(element, "qti-calculator").map((calculator) => {
574
+ const calculatorInfo = firstChildElement(calculator, "qti-calculator-info");
575
+
576
+ return {
577
+ calculatorType:
578
+ (firstChildElement(calculator, "qti-calculator-type")
579
+ ? textContent(firstChildElement(calculator, "qti-calculator-type")!)
580
+ : undefined) ?? "",
581
+ description:
582
+ (firstChildElement(calculator, "qti-description")
583
+ ? textContent(firstChildElement(calculator, "qti-description")!)
584
+ : undefined) ?? "",
585
+ ...(calculatorInfo ? { calculatorInfo: mapV3ItemFileInfo(calculatorInfo) } : {}),
586
+ };
587
+ });
588
+ const rules = childElements(element, "qti-rule").map((rule) => {
589
+ const si = firstChildElement(rule, "qti-rule-system-si");
590
+ const us = firstChildElement(rule, "qti-rule-system-us");
591
+
592
+ return {
593
+ description:
594
+ (firstChildElement(rule, "qti-description")
595
+ ? textContent(firstChildElement(rule, "qti-description")!)
596
+ : undefined) ?? "",
597
+ ...(si ? { ruleSystemSi: mapV3CompanionRuleSystem(si) } : {}),
598
+ ...(us ? { ruleSystemUs: mapV3CompanionRuleSystem(us) } : {}),
599
+ };
600
+ });
601
+ const protractors = childElements(element, "qti-protractor").map((protractor) => {
602
+ const si = firstChildElement(protractor, "qti-increment-si");
603
+ const us = firstChildElement(protractor, "qti-increment-us");
604
+
605
+ return {
606
+ description:
607
+ (firstChildElement(protractor, "qti-description")
608
+ ? textContent(firstChildElement(protractor, "qti-description")!)
609
+ : undefined) ?? "",
610
+ ...(si ? { incrementSi: mapV3ProtractorIncrement(si) } : {}),
611
+ ...(us ? { incrementUs: mapV3ProtractorIncrement(us) } : {}),
612
+ };
613
+ });
614
+ const digitalMaterials = childElements(element, "qti-digital-material").map((material) =>
615
+ mapV3ItemFileInfo(material),
616
+ );
617
+ const physicalMaterials = childElements(element, "qti-physical-material")
618
+ .map((material) => textContent(material))
619
+ .filter((value): value is string => value !== undefined && value !== "");
620
+
621
+ return {
622
+ ...(calculators.length ? { calculators } : {}),
623
+ ...(rules.length ? { rules } : {}),
624
+ ...(protractors.length ? { protractors } : {}),
625
+ ...(digitalMaterials.length ? { digitalMaterials } : {}),
626
+ ...(physicalMaterials.length ? { physicalMaterials } : {}),
627
+ };
628
+ }
629
+
630
+ function companionMaterialsOf(node: QtiXmlElementNode): { companionMaterialsInfo?: unknown } {
631
+ const info = firstChildElement(node, "qti-companion-materials-info");
632
+ return info ? { companionMaterialsInfo: mapV3CompanionMaterialsInfo(info) } : {};
633
+ }
634
+
635
+ // ---------- Catalogs (CatalogInfo/Catalog/Card/CardEntry, §5.26–5.29) ----------
636
+
637
+ /** data-* extension characteristics minus the prefix — the card-entry discriminators (§5.27.3). */
638
+ function dataAttributesOf(element: QtiXmlElementNode): { dataAttributes?: Record<string, string> } {
639
+ const entries = Object.entries(element.attributes)
640
+ .filter(([name]) => name.startsWith("data-"))
641
+ .map(([name, value]) => [name.slice("data-".length), value] as const);
642
+
643
+ return entries.length ? { dataAttributes: Object.fromEntries(entries) } : {};
644
+ }
645
+
646
+ function mapV3CatalogHtmlContent(element: QtiXmlElementNode) {
647
+ const content = mapV3ContentFragments(element.children);
648
+
649
+ return {
650
+ ...optionalString(element.attributes, "xml:lang", "xmlLang"),
651
+ ...dataAttributesOf(element),
652
+ ...(content.length ? { content } : {}),
653
+ };
654
+ }
655
+
656
+ /** The CardSelection content (§6.6): direct HTML content and/or content-file links. */
657
+ function cardContentOf(element: QtiXmlElementNode) {
658
+ const htmlContent = firstChildElement(element, "qti-html-content");
659
+ const fileHrefs = childElements(element, "qti-file-href");
660
+
661
+ return {
662
+ ...(htmlContent ? { htmlContent: mapV3CatalogHtmlContent(htmlContent) } : {}),
663
+ ...(fileHrefs.length
664
+ ? {
665
+ fileHrefs: fileHrefs.map((fileHref) => ({
666
+ href: textContent(fileHref) ?? "",
667
+ mimeType: requireAttribute(fileHref, "mime-type"),
668
+ })),
669
+ }
670
+ : {}),
671
+ };
672
+ }
673
+
674
+ function mapV3CardEntry(element: QtiXmlElementNode) {
675
+ return {
676
+ ...optionalString(element.attributes, "xml:lang", "xmlLang"),
677
+ ...optionalBoolean(element.attributes, "default", "default"),
678
+ ...dataAttributesOf(element),
679
+ ...cardContentOf(element),
680
+ };
681
+ }
682
+
683
+ function mapV3Card(element: QtiXmlElementNode) {
684
+ const entries = childElements(element, "qti-card-entry");
685
+
686
+ return {
687
+ support: requireAttribute(element, "support"),
688
+ ...optionalString(element.attributes, "xml:lang", "xmlLang"),
689
+ // The XSD choice: card entries, or direct content (qti-html-content/qti-file-href).
690
+ ...(entries.length ? { cardEntries: entries.map((entry) => mapV3CardEntry(entry)) } : cardContentOf(element)),
691
+ };
692
+ }
693
+
694
+ function mapV3CatalogInfo(element: QtiXmlElementNode) {
695
+ return {
696
+ catalogs: childElements(element, "qti-catalog").map((catalog) => ({
697
+ id: requireAttribute(catalog, "id"),
698
+ cards: childElements(catalog, "qti-card").map((card) => mapV3Card(card)),
699
+ })),
700
+ };
701
+ }
702
+
703
+ /** The dormant alternative content attached to catalog-bearing nodes (§5.29). */
704
+ function catalogInfoOf(node: QtiXmlElementNode): { catalogInfo?: unknown } {
705
+ const catalogInfo = firstChildElement(node, "qti-catalog-info");
706
+ return catalogInfo ? { catalogInfo: mapV3CatalogInfo(catalogInfo) } : {};
707
+ }
708
+
709
+ /** Body fragments of `node` excluding the element names mapped into dedicated fields. */
710
+ function fragmentsExcluding(node: QtiXmlElementNode, excluded: ReadonlySet<string>): unknown[] {
711
+ return mapV3ContentFragments(
712
+ node.children.filter((child) => child.type !== "element" || !excluded.has(child.localName)),
713
+ );
714
+ }
715
+
716
+ function mapV3Prompt(node: QtiXmlElementNode): unknown {
717
+ return { kind: "prompt", content: mapV3ContentFragments(node.children) };
718
+ }
719
+
720
+ function promptOf(node: QtiXmlElementNode): { prompt?: unknown } {
721
+ const prompt = firstChildElement(node, "qti-prompt");
722
+ return prompt ? { prompt: mapV3Prompt(prompt) } : {};
723
+ }
724
+
725
+ function interactionBase(node: QtiXmlElementNode) {
726
+ return { responseIdentifier: requireAttribute(node, "response-identifier") };
727
+ }
728
+
729
+ /** The stage media of graphic/media interactions: the first non-QTI element child. */
730
+ function requireV3StageMedia(node: QtiXmlElementNode): unknown {
731
+ const media = node.children.find(
732
+ (child): child is QtiXmlElementNode => child.type === "element" && !child.localName.startsWith("qti-"),
733
+ );
734
+ if (!media) {
735
+ throw new Error(`<${node.localName}> must contain a stage <object>, <img>, or media element.`);
736
+ }
737
+ return mapV3XmlNode(media);
738
+ }
739
+
740
+ function mapV3HotspotChoice(node: QtiXmlElementNode, kind: "hotspotChoice" | "associableHotspot"): unknown {
741
+ return {
742
+ kind,
743
+ identifier: requireAttribute(node, "identifier"),
744
+ shape: requireAttribute(node, "shape"),
745
+ coords: requireAttribute(node, "coords"),
746
+ ...(kind === "associableHotspot" ? optionalNumber(node.attributes, "match-max", "matchMax") : {}),
747
+ ...optionalString(node.attributes, "hotspot-label", "hotspotLabel"),
748
+ ...(attributeList(node.attributes["match-group"])
749
+ ? { matchGroup: attributeList(node.attributes["match-group"]) }
750
+ : {}),
751
+ };
752
+ }
753
+
754
+ function mapV3GapChoice(node: QtiXmlElementNode): unknown {
755
+ if (node.localName === "qti-gap-text") {
756
+ return {
757
+ kind: "gapText",
758
+ identifier: requireAttribute(node, "identifier"),
759
+ matchMax: requireNumberAttribute(node, "match-max"),
760
+ ...optionalNumber(node.attributes, "match-min", "matchMin"),
761
+ ...contentOf(node),
762
+ };
763
+ }
764
+
765
+ return {
766
+ kind: "gapImg",
767
+ identifier: requireAttribute(node, "identifier"),
768
+ matchMax: requireNumberAttribute(node, "match-max"),
769
+ ...optionalNumber(node.attributes, "match-min", "matchMin"),
770
+ ...optionalString(node.attributes, "object-label", "objectLabel"),
771
+ ...optionalString(node.attributes, "top", "top"),
772
+ ...optionalString(node.attributes, "left", "left"),
773
+ media: requireV3StageMedia(node),
774
+ };
775
+ }
776
+
777
+ function mapV3SimpleAssociableChoice(node: QtiXmlElementNode): unknown {
778
+ return {
779
+ kind: "simpleAssociableChoice",
780
+ identifier: requireAttribute(node, "identifier"),
781
+ matchMax: requireNumberAttribute(node, "match-max"),
782
+ ...optionalNumber(node.attributes, "match-min", "matchMin"),
783
+ ...optionalBoolean(node.attributes, "fixed", "fixed"),
784
+ ...(attributeList(node.attributes["match-group"])
785
+ ? { matchGroup: attributeList(node.attributes["match-group"]) }
786
+ : {}),
787
+ ...contentOf(node),
788
+ };
789
+ }
790
+
791
+ function mapV3PositionObjectInteraction(node: QtiXmlElementNode, stageImage: unknown): unknown {
792
+ const centerPoint = attributeList(node.attributes["center-point"])?.map(Number);
793
+
794
+ return {
795
+ kind: "positionObjectInteraction",
796
+ ...interactionBase(node),
797
+ image: stageImage,
798
+ ...(centerPoint ? { centerPoint } : {}),
799
+ ...optionalNumber(node.attributes, "min-choices", "minChoices"),
800
+ ...optionalNumber(node.attributes, "max-choices", "maxChoices"),
801
+ };
802
+ }
803
+
804
+ const promptOnly = new Set(["qti-prompt"]);
805
+ const gapMatchOwnedNames = new Set(["qti-prompt", "qti-gap-text", "qti-gap-img"]);
806
+
807
+ /** Map one QTI 3 domain content element to its contracts node. */
808
+ function mapV3DomainNode(node: QtiXmlElementNode): unknown {
809
+ switch (node.localName) {
810
+ case "qti-prompt":
811
+ return mapV3Prompt(node);
812
+
813
+ case "qti-simple-choice":
814
+ return {
815
+ kind: "simpleChoice",
816
+ identifier: requireAttribute(node, "identifier"),
817
+ ...optionalBoolean(node.attributes, "fixed", "fixed"),
818
+ ...optionalString(node.attributes, "template-identifier", "templateIdentifier"),
819
+ ...optionalString(node.attributes, "show-hide", "showHide"),
820
+ ...contentOf(node),
821
+ };
822
+
823
+ case "qti-choice-interaction":
824
+ case "qti-order-interaction":
825
+ return {
826
+ kind: node.localName === "qti-choice-interaction" ? "choiceInteraction" : "orderInteraction",
827
+ ...interactionBase(node),
828
+ ...optionalBoolean(node.attributes, "shuffle", "shuffle"),
829
+ ...optionalNumber(node.attributes, "max-choices", "maxChoices"),
830
+ ...optionalNumber(node.attributes, "min-choices", "minChoices"),
831
+ ...optionalString(node.attributes, "orientation", "orientation"),
832
+ ...promptOf(node),
833
+ simpleChoices: childElements(node, "qti-simple-choice").map((choice) => mapV3DomainNode(choice)),
834
+ };
835
+
836
+ case "qti-inline-choice":
837
+ return {
838
+ kind: "inlineChoice",
839
+ identifier: requireAttribute(node, "identifier"),
840
+ ...optionalBoolean(node.attributes, "fixed", "fixed"),
841
+ ...optionalString(node.attributes, "template-identifier", "templateIdentifier"),
842
+ ...optionalString(node.attributes, "show-hide", "showHide"),
843
+ ...contentOf(node),
844
+ };
845
+
846
+ case "qti-inline-choice-interaction":
847
+ return {
848
+ kind: "inlineChoiceInteraction",
849
+ ...interactionBase(node),
850
+ ...optionalBoolean(node.attributes, "shuffle", "shuffle"),
851
+ ...optionalBoolean(node.attributes, "required", "required"),
852
+ ...optionalNumber(node.attributes, "min-choices", "minChoices"),
853
+ ...optionalString(node.attributes, "data-prompt", "dataPrompt"),
854
+ inlineChoices: childElements(node, "qti-inline-choice").map((choice) => mapV3DomainNode(choice)),
855
+ };
856
+
857
+ case "qti-text-entry-interaction":
858
+ return {
859
+ kind: "textEntryInteraction",
860
+ ...interactionBase(node),
861
+ ...optionalNumber(node.attributes, "base", "base"),
862
+ ...optionalString(node.attributes, "string-identifier", "stringIdentifier"),
863
+ ...optionalNumber(node.attributes, "expected-length", "expectedLength"),
864
+ ...optionalString(node.attributes, "pattern-mask", "patternMask"),
865
+ ...optionalString(node.attributes, "placeholder-text", "placeholderText"),
866
+ ...optionalString(node.attributes, "format", "format"),
867
+ };
868
+
869
+ case "qti-extended-text-interaction":
870
+ return {
871
+ kind: "extendedTextInteraction",
872
+ ...interactionBase(node),
873
+ ...optionalNumber(node.attributes, "base", "base"),
874
+ ...optionalString(node.attributes, "string-identifier", "stringIdentifier"),
875
+ ...optionalNumber(node.attributes, "expected-length", "expectedLength"),
876
+ ...optionalString(node.attributes, "pattern-mask", "patternMask"),
877
+ ...optionalString(node.attributes, "placeholder-text", "placeholderText"),
878
+ ...optionalNumber(node.attributes, "max-strings", "maxStrings"),
879
+ ...optionalNumber(node.attributes, "min-strings", "minStrings"),
880
+ ...optionalNumber(node.attributes, "expected-lines", "expectedLines"),
881
+ ...optionalString(node.attributes, "format", "format"),
882
+ ...promptOf(node),
883
+ };
884
+
885
+ case "qti-hottext":
886
+ return {
887
+ kind: "hotText",
888
+ identifier: requireAttribute(node, "identifier"),
889
+ ...optionalString(node.attributes, "template-identifier", "templateIdentifier"),
890
+ ...optionalString(node.attributes, "show-hide", "showHide"),
891
+ ...contentOf(node),
892
+ };
893
+
894
+ case "qti-hottext-interaction":
895
+ return {
896
+ kind: "hotTextInteraction",
897
+ ...interactionBase(node),
898
+ ...optionalNumber(node.attributes, "max-choices", "maxChoices"),
899
+ ...optionalNumber(node.attributes, "min-choices", "minChoices"),
900
+ ...promptOf(node),
901
+ content: fragmentsExcluding(node, promptOnly),
902
+ };
903
+
904
+ case "qti-match-interaction":
905
+ return {
906
+ kind: "matchInteraction",
907
+ ...interactionBase(node),
908
+ ...optionalBoolean(node.attributes, "shuffle", "shuffle"),
909
+ ...optionalNumber(node.attributes, "max-associations", "maxAssociations"),
910
+ ...optionalNumber(node.attributes, "min-associations", "minAssociations"),
911
+ ...optionalString(node.attributes, "data-first-column-header", "dataFirstColumnHeader"),
912
+ ...promptOf(node),
913
+ simpleMatchSets: childElements(node, "qti-simple-match-set").map((set) => ({
914
+ kind: "simpleMatchSet",
915
+ simpleAssociableChoices: childElements(set, "qti-simple-associable-choice").map((choice) =>
916
+ mapV3SimpleAssociableChoice(choice),
917
+ ),
918
+ })),
919
+ };
920
+
921
+ case "qti-simple-associable-choice":
922
+ return mapV3SimpleAssociableChoice(node);
923
+
924
+ case "qti-associate-interaction":
925
+ return {
926
+ kind: "associateInteraction",
927
+ ...interactionBase(node),
928
+ ...optionalBoolean(node.attributes, "shuffle", "shuffle"),
929
+ ...optionalNumber(node.attributes, "max-associations", "maxAssociations"),
930
+ ...optionalNumber(node.attributes, "min-associations", "minAssociations"),
931
+ ...promptOf(node),
932
+ simpleAssociableChoices: childElements(node, "qti-simple-associable-choice").map((choice) =>
933
+ mapV3SimpleAssociableChoice(choice),
934
+ ),
935
+ };
936
+
937
+ case "qti-gap":
938
+ return {
939
+ kind: "gap",
940
+ identifier: requireAttribute(node, "identifier"),
941
+ ...optionalBoolean(node.attributes, "required", "required"),
942
+ ...optionalString(node.attributes, "template-identifier", "templateIdentifier"),
943
+ ...optionalString(node.attributes, "show-hide", "showHide"),
944
+ };
945
+
946
+ case "qti-gap-text":
947
+ case "qti-gap-img":
948
+ return mapV3GapChoice(node);
949
+
950
+ case "qti-gap-match-interaction":
951
+ return {
952
+ kind: "gapMatchInteraction",
953
+ ...interactionBase(node),
954
+ ...optionalBoolean(node.attributes, "shuffle", "shuffle"),
955
+ ...optionalNumber(node.attributes, "max-associations", "maxAssociations"),
956
+ ...optionalNumber(node.attributes, "min-associations", "minAssociations"),
957
+ ...promptOf(node),
958
+ gapChoices: childElements(node)
959
+ .filter((child) => child.localName === "qti-gap-text" || child.localName === "qti-gap-img")
960
+ .map((choice) => mapV3GapChoice(choice)),
961
+ content: fragmentsExcluding(node, gapMatchOwnedNames),
962
+ };
963
+
964
+ case "qti-hotspot-choice":
965
+ return mapV3HotspotChoice(node, "hotspotChoice");
966
+
967
+ case "qti-associable-hotspot":
968
+ return mapV3HotspotChoice(node, "associableHotspot");
969
+
970
+ case "qti-hotspot-interaction":
971
+ case "qti-graphic-order-interaction":
972
+ return {
973
+ kind: node.localName === "qti-hotspot-interaction" ? "hotspotInteraction" : "graphicOrderInteraction",
974
+ ...interactionBase(node),
975
+ ...optionalNumber(node.attributes, "max-choices", "maxChoices"),
976
+ ...optionalNumber(node.attributes, "min-choices", "minChoices"),
977
+ ...promptOf(node),
978
+ image: requireV3StageMedia(node),
979
+ hotspotChoices: childElements(node, "qti-hotspot-choice").map((choice) =>
980
+ mapV3HotspotChoice(choice, "hotspotChoice"),
981
+ ),
982
+ };
983
+
984
+ case "qti-graphic-associate-interaction":
985
+ return {
986
+ kind: "graphicAssociateInteraction",
987
+ ...interactionBase(node),
988
+ ...optionalNumber(node.attributes, "max-associations", "maxAssociations"),
989
+ ...optionalNumber(node.attributes, "min-associations", "minAssociations"),
990
+ ...promptOf(node),
991
+ image: requireV3StageMedia(node),
992
+ associableHotspots: childElements(node, "qti-associable-hotspot").map((choice) =>
993
+ mapV3HotspotChoice(choice, "associableHotspot"),
994
+ ),
995
+ };
996
+
997
+ case "qti-graphic-gap-match-interaction":
998
+ return {
999
+ kind: "graphicGapMatchInteraction",
1000
+ ...interactionBase(node),
1001
+ ...optionalNumber(node.attributes, "max-associations", "maxAssociations"),
1002
+ ...optionalNumber(node.attributes, "min-associations", "minAssociations"),
1003
+ ...promptOf(node),
1004
+ image: requireV3StageMedia(node),
1005
+ gapChoices: childElements(node)
1006
+ .filter((child) => child.localName === "qti-gap-text" || child.localName === "qti-gap-img")
1007
+ .map((choice) => mapV3GapChoice(choice)),
1008
+ associableHotspots: childElements(node, "qti-associable-hotspot").map((choice) =>
1009
+ mapV3HotspotChoice(choice, "associableHotspot"),
1010
+ ),
1011
+ };
1012
+
1013
+ case "qti-select-point-interaction":
1014
+ return {
1015
+ kind: "selectPointInteraction",
1016
+ ...interactionBase(node),
1017
+ ...optionalNumber(node.attributes, "max-choices", "maxChoices"),
1018
+ ...optionalNumber(node.attributes, "min-choices", "minChoices"),
1019
+ ...promptOf(node),
1020
+ image: requireV3StageMedia(node),
1021
+ };
1022
+
1023
+ case "qti-position-object-stage": {
1024
+ const image = requireV3StageMedia(node);
1025
+
1026
+ return {
1027
+ kind: "positionObjectStage",
1028
+ image,
1029
+ positionObjectInteractions: childElements(node, "qti-position-object-interaction").map((interaction) =>
1030
+ mapV3PositionObjectInteraction(interaction, image),
1031
+ ),
1032
+ };
1033
+ }
1034
+
1035
+ case "qti-position-object-interaction":
1036
+ throw new Error("<qti-position-object-interaction> is only supported inside <qti-position-object-stage>.");
1037
+
1038
+ case "qti-media-interaction":
1039
+ return {
1040
+ kind: "mediaInteraction",
1041
+ ...interactionBase(node),
1042
+ autostart: attributeBoolean(node.attributes, "autostart") ?? false,
1043
+ ...optionalNumber(node.attributes, "min-plays", "minPlays"),
1044
+ ...optionalNumber(node.attributes, "max-plays", "maxPlays"),
1045
+ ...optionalBoolean(node.attributes, "loop", "loop"),
1046
+ ...optionalString(node.attributes, "coords", "coords"),
1047
+ ...promptOf(node),
1048
+ media: requireV3StageMedia(node),
1049
+ };
1050
+
1051
+ case "qti-upload-interaction":
1052
+ return {
1053
+ kind: "uploadInteraction",
1054
+ ...interactionBase(node),
1055
+ ...promptOf(node),
1056
+ ...(attributeList(node.attributes["type"]) ? { acceptedTypes: attributeList(node.attributes["type"]) } : {}),
1057
+ };
1058
+
1059
+ case "qti-slider-interaction":
1060
+ return {
1061
+ kind: "sliderInteraction",
1062
+ ...interactionBase(node),
1063
+ lowerBound: requireNumberAttribute(node, "lower-bound"),
1064
+ upperBound: requireNumberAttribute(node, "upper-bound"),
1065
+ ...optionalNumber(node.attributes, "step", "step"),
1066
+ ...optionalBoolean(node.attributes, "step-label", "stepLabel"),
1067
+ ...optionalString(node.attributes, "orientation", "orientation"),
1068
+ ...optionalBoolean(node.attributes, "reverse", "reverse"),
1069
+ ...promptOf(node),
1070
+ };
1071
+
1072
+ case "qti-end-attempt-interaction":
1073
+ return {
1074
+ kind: "endAttemptInteraction",
1075
+ ...interactionBase(node),
1076
+ title: requireAttribute(node, "title"),
1077
+ };
1078
+
1079
+ case "qti-feedback-inline":
1080
+ case "qti-feedback-block":
1081
+ return {
1082
+ kind: node.localName === "qti-feedback-inline" ? "feedbackInline" : "feedbackBlock",
1083
+ outcomeIdentifier: requireAttribute(node, "outcome-identifier"),
1084
+ identifier: requireAttribute(node, "identifier"),
1085
+ ...optionalString(node.attributes, "show-hide", "showHide"),
1086
+ ...contentOf(node),
1087
+ ...catalogInfoOf(node),
1088
+ };
1089
+
1090
+ case "qti-template-inline":
1091
+ case "qti-template-block":
1092
+ return {
1093
+ kind: node.localName === "qti-template-inline" ? "templateInline" : "templateBlock",
1094
+ templateIdentifier: requireAttribute(node, "template-identifier"),
1095
+ identifier: requireAttribute(node, "identifier"),
1096
+ ...optionalString(node.attributes, "show-hide", "showHide"),
1097
+ ...contentOf(node),
1098
+ ...catalogInfoOf(node),
1099
+ };
1100
+
1101
+ case "qti-printed-variable":
1102
+ return {
1103
+ kind: "printedVariable",
1104
+ identifier: requireAttribute(node, "identifier"),
1105
+ ...optionalString(node.attributes, "format", "format"),
1106
+ ...optionalNumber(node.attributes, "base", "base"),
1107
+ ...optionalNumber(node.attributes, "index", "index"),
1108
+ ...optionalBoolean(node.attributes, "power-form", "powerForm"),
1109
+ ...optionalString(node.attributes, "field", "field"),
1110
+ ...optionalString(node.attributes, "delimiter", "delimiter"),
1111
+ ...optionalString(node.attributes, "mapping-indicator", "mappingIndicator"),
1112
+ };
1113
+
1114
+ case "qti-rubric-block":
1115
+ return {
1116
+ kind: "rubricBlock",
1117
+ view: attributeList(requireAttribute(node, "view")) ?? [],
1118
+ ...optionalString(node.attributes, "use", "use"),
1119
+ ...contentOf(node),
1120
+ ...catalogInfoOf(node),
1121
+ };
1122
+
1123
+ case "qti-include":
1124
+ return {
1125
+ kind: "include",
1126
+ ...optionalString(node.attributes, "href", "href"),
1127
+ ...optionalString(node.attributes, "parse", "parse"),
1128
+ ...optionalString(node.attributes, "xpointer", "xpointer"),
1129
+ };
1130
+
1131
+ case "qti-portable-custom-interaction": {
1132
+ const markup = firstChildElement(node, "qti-interaction-markup");
1133
+ const modules = firstChildElement(node, "qti-interaction-modules");
1134
+ // PCI configuration properties: every data-* attribute except the reserved QTI
1135
+ // ones (catalog/TTS/SSML), keyed by the name minus its "data-" prefix.
1136
+ const reservedDataAttributes = new Set(["data-catalog-idref", "data-ssml"]);
1137
+ const properties = Object.fromEntries(
1138
+ Object.entries(node.attributes)
1139
+ .filter(
1140
+ ([name]) => name.startsWith("data-") && !reservedDataAttributes.has(name) && !name.startsWith("data-qti-"),
1141
+ )
1142
+ .map(([name, value]) => [name.slice("data-".length), value]),
1143
+ );
1144
+ const classTokens = (node.attributes["class"] ?? "").split(/\s+/u).filter((token) => token.length > 0);
1145
+
1146
+ return {
1147
+ kind: "portableCustomInteraction",
1148
+ ...interactionBase(node),
1149
+ customInteractionTypeIdentifier: requireAttribute(node, "custom-interaction-type-identifier"),
1150
+ ...optionalString(node.attributes, "module", "module"),
1151
+ ...(classTokens.length > 0 ? { class: classTokens } : {}),
1152
+ ...optionalString(node.attributes, "data-catalog-idref", "dataCatalogIdref"),
1153
+ ...(Object.keys(properties).length > 0 ? { properties } : {}),
1154
+ ...catalogInfoOf(node),
1155
+ interactionMarkup: {
1156
+ kind: "interactionMarkup",
1157
+ ...(markup ? contentOf(markup) : {}),
1158
+ },
1159
+ ...(modules
1160
+ ? {
1161
+ interactionModules: {
1162
+ kind: "interactionModules",
1163
+ ...optionalString(modules.attributes, "primary-configuration", "primaryConfiguration"),
1164
+ ...optionalString(modules.attributes, "secondary-configuration", "secondaryConfiguration"),
1165
+ modules: childElements(modules, "qti-interaction-module").map((moduleElement) => ({
1166
+ kind: "interactionModule",
1167
+ id: requireAttribute(moduleElement, "id"),
1168
+ ...optionalString(moduleElement.attributes, "primary-path", "primaryPath"),
1169
+ ...optionalString(moduleElement.attributes, "fallback-path", "fallbackPath"),
1170
+ })),
1171
+ },
1172
+ }
1173
+ : {}),
1174
+ };
1175
+ }
1176
+
1177
+ case "qti-custom-interaction":
1178
+ return {
1179
+ kind: "customInteraction",
1180
+ ...interactionBase(node),
1181
+ ...contentOf(node),
1182
+ };
1183
+
1184
+ case "qti-drawing-interaction":
1185
+ return {
1186
+ kind: "drawingInteraction",
1187
+ ...interactionBase(node),
1188
+ ...promptOf(node),
1189
+ content: fragmentsExcluding(node, promptOnly),
1190
+ };
1191
+
1192
+ default:
1193
+ throw new Error(`Unsupported QTI 3.0.1 content element <${node.localName}> in normalization.`);
1194
+ }
1195
+ }
1196
+
1197
+ function mapV3ContentFragments(nodes: QtiXmlNode[]): unknown[] {
1198
+ const content: unknown[] = [];
1199
+
1200
+ for (const node of nodes) {
1201
+ if (node.type === "text") {
1202
+ const value = normalizeTextValue(node.value);
1203
+ if (value) {
1204
+ content.push(value);
1205
+ }
1206
+ continue;
1207
+ }
1208
+
1209
+ if (qtiV30DomainContentNames.has(node.localName)) {
1210
+ content.push(mapV3DomainNode(node));
1211
+ continue;
1212
+ }
1213
+
1214
+ content.push(mapV3XmlNode(node));
1215
+ }
1216
+
1217
+ return content;
1218
+ }
1219
+
1220
+ /** Expression elements whose contracts node is `{ kind, children }` with no attributes. */
1221
+ const v3ChildOnlyExpressionNames = new Map<string, string>([
1222
+ ["qti-and", "and"],
1223
+ ["qti-contains", "contains"],
1224
+ ["qti-container-size", "containerSize"],
1225
+ ["qti-delete", "delete"],
1226
+ ["qti-divide", "divide"],
1227
+ ["qti-duration-gte", "durationGte"],
1228
+ ["qti-duration-lt", "durationLt"],
1229
+ ["qti-gcd", "gcd"],
1230
+ ["qti-gt", "gt"],
1231
+ ["qti-gte", "gte"],
1232
+ ["qti-integer-divide", "integerDivide"],
1233
+ ["qti-integer-modulus", "integerModulus"],
1234
+ ["qti-integer-to-float", "integerToFloat"],
1235
+ ["qti-is-null", "isNull"],
1236
+ ["qti-lcm", "lcm"],
1237
+ ["qti-lt", "lt"],
1238
+ ["qti-lte", "lte"],
1239
+ ["qti-match", "match"],
1240
+ ["qti-max", "max"],
1241
+ ["qti-member", "member"],
1242
+ ["qti-min", "min"],
1243
+ ["qti-multiple", "multiple"],
1244
+ ["qti-not", "not"],
1245
+ ["qti-or", "or"],
1246
+ ["qti-ordered", "ordered"],
1247
+ ["qti-power", "power"],
1248
+ ["qti-product", "product"],
1249
+ ["qti-random", "random"],
1250
+ ["qti-round", "round"],
1251
+ ["qti-subtract", "subtract"],
1252
+ ["qti-sum", "sum"],
1253
+ ["qti-truncate", "truncate"],
1254
+ ]);
1255
+
1256
+ /** An attribute that is either a numeric literal or a template-variable reference. */
1257
+ function numberOrVariableAttribute(
1258
+ attributes: Record<string, string>,
1259
+ name: string,
1260
+ key: string,
1261
+ ): Record<string, number | string> {
1262
+ const value = attributes[name];
1263
+ if (value === undefined || value === "") {
1264
+ return {};
1265
+ }
1266
+ const parsed = Number(value);
1267
+ return { [key]: Number.isNaN(parsed) ? value : parsed };
1268
+ }
1269
+
1270
+ function expressionChildren(element: QtiXmlElementNode): unknown[] {
1271
+ return childElements(element).map((child) => mapV3Expression(child));
1272
+ }
1273
+
1274
+ function outcomeSubsetSelection(attributes: Record<string, string>) {
1275
+ return {
1276
+ ...optionalString(attributes, "section-identifier", "sectionIdentifier"),
1277
+ ...(attributeList(attributes["include-category"])
1278
+ ? { includeCategory: attributeList(attributes["include-category"]) }
1279
+ : {}),
1280
+ ...(attributeList(attributes["exclude-category"])
1281
+ ? { excludeCategory: attributeList(attributes["exclude-category"]) }
1282
+ : {}),
1283
+ };
1284
+ }
1285
+
1286
+ function mapV3Expression(element: QtiXmlElementNode): unknown {
1287
+ const childOnlyKind = v3ChildOnlyExpressionNames.get(element.localName);
1288
+ if (childOnlyKind !== undefined) {
1289
+ return { kind: childOnlyKind, children: expressionChildren(element) };
1290
+ }
1291
+
1292
+ switch (element.localName) {
1293
+ case "qti-null":
1294
+ return { kind: "null" };
1295
+ case "qti-base-value":
1296
+ return {
1297
+ kind: "baseValue",
1298
+ baseType: requireAttribute(element, "base-type"),
1299
+ value: textContent(element) ?? "",
1300
+ };
1301
+ case "qti-variable":
1302
+ return {
1303
+ kind: "variable",
1304
+ identifier: requireAttribute(element, "identifier"),
1305
+ ...optionalString(element.attributes, "weight-identifier", "weightIdentifier"),
1306
+ };
1307
+ case "qti-correct":
1308
+ return { kind: "correct", identifier: requireAttribute(element, "identifier") };
1309
+ case "qti-default":
1310
+ return { kind: "default", identifier: requireAttribute(element, "identifier") };
1311
+ case "qti-map-response":
1312
+ return { kind: "mapResponse", identifier: requireAttribute(element, "identifier") };
1313
+ case "qti-map-response-point":
1314
+ return { kind: "mapResponsePoint", identifier: requireAttribute(element, "identifier") };
1315
+ case "qti-random-integer":
1316
+ return {
1317
+ kind: "randomInteger",
1318
+ ...numberOrVariableAttribute(element.attributes, "min", "min"),
1319
+ ...numberOrVariableAttribute(element.attributes, "max", "max"),
1320
+ ...numberOrVariableAttribute(element.attributes, "step", "step"),
1321
+ };
1322
+ case "qti-random-float":
1323
+ return {
1324
+ kind: "randomFloat",
1325
+ ...numberOrVariableAttribute(element.attributes, "min", "min"),
1326
+ ...numberOrVariableAttribute(element.attributes, "max", "max"),
1327
+ };
1328
+ case "qti-math-constant":
1329
+ return { kind: "mathConstant", name: requireAttribute(element, "name") };
1330
+ case "qti-math-operator":
1331
+ return { kind: "mathOperator", name: requireAttribute(element, "name"), children: expressionChildren(element) };
1332
+ case "qti-stats-operator":
1333
+ return { kind: "statsOperator", name: requireAttribute(element, "name"), children: expressionChildren(element) };
1334
+ case "qti-any-n":
1335
+ return {
1336
+ kind: "anyN",
1337
+ ...numberOrVariableAttribute(element.attributes, "min", "min"),
1338
+ ...numberOrVariableAttribute(element.attributes, "max", "max"),
1339
+ children: expressionChildren(element),
1340
+ };
1341
+ case "qti-equal": {
1342
+ const tolerance = attributeList(element.attributes["tolerance"])?.map((entry) => {
1343
+ const parsed = Number(entry);
1344
+ return Number.isNaN(parsed) ? entry : parsed;
1345
+ });
1346
+
1347
+ return {
1348
+ kind: "equal",
1349
+ ...optionalString(element.attributes, "tolerance-mode", "toleranceMode"),
1350
+ ...(tolerance ? { tolerance } : {}),
1351
+ ...optionalBoolean(element.attributes, "include-lower-bound", "includeLowerBound"),
1352
+ ...optionalBoolean(element.attributes, "include-upper-bound", "includeUpperBound"),
1353
+ children: expressionChildren(element),
1354
+ };
1355
+ }
1356
+ case "qti-equal-rounded":
1357
+ return {
1358
+ kind: "equalRounded",
1359
+ ...optionalString(element.attributes, "rounding-mode", "roundingMode"),
1360
+ ...numberOrVariableAttribute(element.attributes, "figures", "figures"),
1361
+ children: expressionChildren(element),
1362
+ };
1363
+ case "qti-round-to":
1364
+ return {
1365
+ kind: "roundTo",
1366
+ roundingMode: requireAttribute(element, "rounding-mode"),
1367
+ ...numberOrVariableAttribute(element.attributes, "figures", "figures"),
1368
+ children: expressionChildren(element),
1369
+ };
1370
+ case "qti-field-value":
1371
+ return {
1372
+ kind: "fieldValue",
1373
+ fieldIdentifier: requireAttribute(element, "field-identifier"),
1374
+ children: expressionChildren(element),
1375
+ };
1376
+ case "qti-index":
1377
+ return {
1378
+ kind: "index",
1379
+ ...numberOrVariableAttribute(element.attributes, "n", "n"),
1380
+ children: expressionChildren(element),
1381
+ };
1382
+ case "qti-inside":
1383
+ return {
1384
+ kind: "inside",
1385
+ shape: requireAttribute(element, "shape"),
1386
+ coords: requireAttribute(element, "coords"),
1387
+ children: expressionChildren(element),
1388
+ };
1389
+ case "qti-pattern-match":
1390
+ return {
1391
+ kind: "patternMatch",
1392
+ pattern: requireAttribute(element, "pattern"),
1393
+ children: expressionChildren(element),
1394
+ };
1395
+ case "qti-string-match":
1396
+ return {
1397
+ kind: "stringMatch",
1398
+ caseSensitive: attributeBoolean(element.attributes, "case-sensitive") ?? true,
1399
+ ...optionalBoolean(element.attributes, "substring", "substring"),
1400
+ children: expressionChildren(element),
1401
+ };
1402
+ case "qti-substring":
1403
+ return {
1404
+ kind: "substring",
1405
+ caseSensitive: attributeBoolean(element.attributes, "case-sensitive") ?? true,
1406
+ children: expressionChildren(element),
1407
+ };
1408
+ case "qti-repeat":
1409
+ return {
1410
+ kind: "repeat",
1411
+ ...numberOrVariableAttribute(element.attributes, "number-repeats", "numberRepeats"),
1412
+ children: expressionChildren(element),
1413
+ };
1414
+ case "qti-custom-operator":
1415
+ return {
1416
+ kind: "customOperator",
1417
+ ...optionalString(element.attributes, "class", "class"),
1418
+ ...optionalString(element.attributes, "definition", "definition"),
1419
+ children: expressionChildren(element),
1420
+ };
1421
+ case "qti-number-correct":
1422
+ return { kind: "numberCorrect", ...outcomeSubsetSelection(element.attributes) };
1423
+ case "qti-number-incorrect":
1424
+ return { kind: "numberIncorrect", ...outcomeSubsetSelection(element.attributes) };
1425
+ case "qti-number-presented":
1426
+ return { kind: "numberPresented", ...outcomeSubsetSelection(element.attributes) };
1427
+ case "qti-number-responded":
1428
+ return { kind: "numberResponded", ...outcomeSubsetSelection(element.attributes) };
1429
+ case "qti-number-selected":
1430
+ return { kind: "numberSelected", ...outcomeSubsetSelection(element.attributes) };
1431
+ case "qti-outcome-minimum":
1432
+ return {
1433
+ kind: "outcomeMinimum",
1434
+ ...outcomeSubsetSelection(element.attributes),
1435
+ outcomeIdentifier: requireAttribute(element, "outcome-identifier"),
1436
+ ...optionalString(element.attributes, "weight-identifier", "weightIdentifier"),
1437
+ };
1438
+ case "qti-outcome-maximum":
1439
+ return {
1440
+ kind: "outcomeMaximum",
1441
+ ...outcomeSubsetSelection(element.attributes),
1442
+ outcomeIdentifier: requireAttribute(element, "outcome-identifier"),
1443
+ ...optionalString(element.attributes, "weight-identifier", "weightIdentifier"),
1444
+ };
1445
+ case "qti-test-variables":
1446
+ return {
1447
+ kind: "testVariables",
1448
+ ...outcomeSubsetSelection(element.attributes),
1449
+ variableIdentifier: requireAttribute(element, "variable-identifier"),
1450
+ ...optionalString(element.attributes, "weight-identifier", "weightIdentifier"),
1451
+ ...optionalString(element.attributes, "base-type", "baseType"),
1452
+ };
1453
+ default:
1454
+ throw new Error(`Unsupported QTI 3.0.1 expression element <${element.localName}> in normalization.`);
1455
+ }
1456
+ }
1457
+
1458
+ /** The condition branch of a response/template condition: first child is the expression, the rest are rules. */
1459
+ function conditionBranch(
1460
+ element: QtiXmlElementNode,
1461
+ kind: string,
1462
+ mapRule: (rule: QtiXmlElementNode) => unknown,
1463
+ ): Record<string, unknown> {
1464
+ const [expressionElement, ...ruleElements] = childElements(element);
1465
+ if (!expressionElement) {
1466
+ throw new Error(`<${element.localName}> must contain an expression.`);
1467
+ }
1468
+
1469
+ const actions = ruleElements.map((rule) => mapRule(rule));
1470
+
1471
+ return {
1472
+ kind,
1473
+ expression: mapV3Expression(expressionElement),
1474
+ ...(actions.length ? { actions } : {}),
1475
+ };
1476
+ }
1477
+
1478
+ function elseBranch(
1479
+ element: QtiXmlElementNode,
1480
+ kind: string,
1481
+ mapRule: (rule: QtiXmlElementNode) => unknown,
1482
+ ): Record<string, unknown> {
1483
+ const actions = childElements(element).map((rule) => mapRule(rule));
1484
+ return { kind, ...(actions.length ? { actions } : {}) };
1485
+ }
1486
+
1487
+ function mapV3ResponseRule(element: QtiXmlElementNode): unknown {
1488
+ switch (element.localName) {
1489
+ case "qti-response-condition": {
1490
+ const responseIf = firstChildElement(element, "qti-response-if");
1491
+ if (!responseIf) {
1492
+ throw new Error("<qti-response-condition> must contain <qti-response-if>.");
1493
+ }
1494
+ const elseIfs = childElements(element, "qti-response-else-if");
1495
+ const responseElse = firstChildElement(element, "qti-response-else");
1496
+
1497
+ return {
1498
+ kind: "responseCondition",
1499
+ responseIf: conditionBranch(responseIf, "responseIf", mapV3ResponseRule),
1500
+ ...(elseIfs.length
1501
+ ? { responseElseIf: elseIfs.map((branch) => conditionBranch(branch, "responseIf", mapV3ResponseRule)) }
1502
+ : {}),
1503
+ ...(responseElse ? { responseElse: elseBranch(responseElse, "responseElse", mapV3ResponseRule) } : {}),
1504
+ };
1505
+ }
1506
+ case "qti-set-outcome-value":
1507
+ case "qti-lookup-outcome-value": {
1508
+ const expressionElement = childElements(element)[0];
1509
+ if (!expressionElement) {
1510
+ throw new Error(`<${element.localName}> must contain an expression.`);
1511
+ }
1512
+
1513
+ return {
1514
+ kind: element.localName === "qti-set-outcome-value" ? "setOutcomeValue" : "lookupOutcomeValue",
1515
+ identifier: requireAttribute(element, "identifier"),
1516
+ expression: mapV3Expression(expressionElement),
1517
+ };
1518
+ }
1519
+ case "qti-exit-response":
1520
+ return { kind: "exitResponse" };
1521
+ case "qti-response-processing-fragment": {
1522
+ const rules = childElements(element).map((rule) => mapV3ResponseRule(rule));
1523
+ return { kind: "responseProcessingFragment", ...(rules.length ? { rules } : {}) };
1524
+ }
1525
+ default:
1526
+ throw new Error(`Unsupported QTI 3.0.1 response rule <${element.localName}> in normalization.`);
1527
+ }
1528
+ }
1529
+
1530
+ function mapV3TemplateRule(element: QtiXmlElementNode): unknown {
1531
+ switch (element.localName) {
1532
+ case "qti-template-condition": {
1533
+ const templateIf = firstChildElement(element, "qti-template-if");
1534
+ if (!templateIf) {
1535
+ throw new Error("<qti-template-condition> must contain <qti-template-if>.");
1536
+ }
1537
+ const elseIfs = childElements(element, "qti-template-else-if");
1538
+ const templateElse = firstChildElement(element, "qti-template-else");
1539
+
1540
+ return {
1541
+ kind: "templateCondition",
1542
+ templateIf: conditionBranch(templateIf, "templateIf", mapV3TemplateRule),
1543
+ ...(elseIfs.length
1544
+ ? { templateElseIf: elseIfs.map((branch) => conditionBranch(branch, "templateIf", mapV3TemplateRule)) }
1545
+ : {}),
1546
+ ...(templateElse ? { templateElse: elseBranch(templateElse, "templateElse", mapV3TemplateRule) } : {}),
1547
+ };
1548
+ }
1549
+ case "qti-set-template-value":
1550
+ case "qti-set-default-value":
1551
+ case "qti-set-correct-response": {
1552
+ const expressionElement = childElements(element)[0];
1553
+ if (!expressionElement) {
1554
+ throw new Error(`<${element.localName}> must contain an expression.`);
1555
+ }
1556
+
1557
+ const kinds: Record<string, string> = {
1558
+ "qti-set-template-value": "setTemplateValue",
1559
+ "qti-set-default-value": "setDefaultValue",
1560
+ "qti-set-correct-response": "setCorrectResponse",
1561
+ };
1562
+
1563
+ return {
1564
+ kind: kinds[element.localName]!,
1565
+ identifier: requireAttribute(element, "identifier"),
1566
+ expression: mapV3Expression(expressionElement),
1567
+ };
1568
+ }
1569
+ case "qti-template-constraint": {
1570
+ const expressionElement = childElements(element)[0];
1571
+ if (!expressionElement) {
1572
+ throw new Error("<qti-template-constraint> must contain an expression.");
1573
+ }
1574
+ return { kind: "templateConstraint", expression: mapV3Expression(expressionElement) };
1575
+ }
1576
+ case "qti-exit-template":
1577
+ return { kind: "exitTemplate" };
1578
+ default:
1579
+ throw new Error(`Unsupported QTI 3.0.1 template rule <${element.localName}> in normalization.`);
1580
+ }
1581
+ }
1582
+
1583
+ function mapV3PreCondition(element: QtiXmlElementNode) {
1584
+ const expressionElement = childElements(element)[0];
1585
+ if (!expressionElement) {
1586
+ throw new Error("<qti-pre-condition> must contain an expression.");
1587
+ }
1588
+
1589
+ return {
1590
+ kind: "preCondition",
1591
+ expression: mapV3Expression(expressionElement),
1592
+ };
1593
+ }
1594
+
1595
+ function mapV3ItemSessionControl(element: QtiXmlElementNode) {
1596
+ return {
1597
+ ...(attributeBoolean(element.attributes, "allow-review") !== undefined
1598
+ ? { allowReview: attributeBoolean(element.attributes, "allow-review") }
1599
+ : {}),
1600
+ ...(attributeNumber(element.attributes, "max-attempts") !== undefined
1601
+ ? { maxAttempts: attributeNumber(element.attributes, "max-attempts") }
1602
+ : {}),
1603
+ ...(attributeBoolean(element.attributes, "show-feedback") !== undefined
1604
+ ? { showFeedback: attributeBoolean(element.attributes, "show-feedback") }
1605
+ : {}),
1606
+ ...(attributeBoolean(element.attributes, "show-solution") !== undefined
1607
+ ? { showSolution: attributeBoolean(element.attributes, "show-solution") }
1608
+ : {}),
1609
+ ...(attributeBoolean(element.attributes, "allow-comment") !== undefined
1610
+ ? { allowComment: attributeBoolean(element.attributes, "allow-comment") }
1611
+ : {}),
1612
+ ...(attributeBoolean(element.attributes, "allow-skipping") !== undefined
1613
+ ? { allowSkipping: attributeBoolean(element.attributes, "allow-skipping") }
1614
+ : {}),
1615
+ ...(attributeBoolean(element.attributes, "validate-responses") !== undefined
1616
+ ? { validateResponses: attributeBoolean(element.attributes, "validate-responses") }
1617
+ : {}),
1618
+ };
1619
+ }
1620
+
1621
+ function mapV3TimeLimits(element: QtiXmlElementNode) {
1622
+ return {
1623
+ ...(attributeNumber(element.attributes, "min-time") !== undefined
1624
+ ? { minTime: attributeNumber(element.attributes, "min-time") }
1625
+ : {}),
1626
+ ...(attributeNumber(element.attributes, "max-time") !== undefined
1627
+ ? { maxTime: attributeNumber(element.attributes, "max-time") }
1628
+ : {}),
1629
+ ...(attributeBoolean(element.attributes, "allow-late-submission") !== undefined
1630
+ ? { allowLateSubmission: attributeBoolean(element.attributes, "allow-late-submission") }
1631
+ : {}),
1632
+ };
1633
+ }
1634
+
1635
+ function mapV3BranchRule(element: QtiXmlElementNode) {
1636
+ const expressionElement = childElements(element)[0];
1637
+ if (!expressionElement) {
1638
+ throw new Error("<qti-branch-rule> must contain an expression.");
1639
+ }
1640
+
1641
+ return {
1642
+ kind: "branchRule",
1643
+ target: requireAttribute(element, "target"),
1644
+ expression: mapV3Expression(expressionElement),
1645
+ };
1646
+ }
1647
+
1648
+ function mapV3OutcomeRule(element: QtiXmlElementNode): unknown {
1649
+ switch (element.localName) {
1650
+ case "qti-outcome-condition": {
1651
+ const outcomeIf = firstChildElement(element, "qti-outcome-if");
1652
+ if (!outcomeIf) {
1653
+ throw new Error("<qti-outcome-condition> must contain <qti-outcome-if>.");
1654
+ }
1655
+ const elseIfs = childElements(element, "qti-outcome-else-if");
1656
+ const outcomeElse = firstChildElement(element, "qti-outcome-else");
1657
+
1658
+ return {
1659
+ kind: "outcomeCondition",
1660
+ outcomeIf: conditionBranch(outcomeIf, "outcomeIf", mapV3OutcomeRule),
1661
+ ...(elseIfs.length
1662
+ ? { outcomeElseIf: elseIfs.map((branch) => conditionBranch(branch, "outcomeIf", mapV3OutcomeRule)) }
1663
+ : {}),
1664
+ ...(outcomeElse ? { outcomeElse: elseBranch(outcomeElse, "outcomeElse", mapV3OutcomeRule) } : {}),
1665
+ };
1666
+ }
1667
+ case "qti-set-outcome-value":
1668
+ case "qti-lookup-outcome-value": {
1669
+ const expressionElement = childElements(element)[0];
1670
+ if (!expressionElement) {
1671
+ throw new Error(`<${element.localName}> must contain an expression.`);
1672
+ }
1673
+
1674
+ return {
1675
+ kind: element.localName === "qti-set-outcome-value" ? "setOutcomeValue" : "lookupOutcomeValue",
1676
+ identifier: requireAttribute(element, "identifier"),
1677
+ expression: mapV3Expression(expressionElement),
1678
+ };
1679
+ }
1680
+ case "qti-exit-test":
1681
+ return { kind: "exitTest" };
1682
+ case "qti-outcome-processing-fragment": {
1683
+ const rules = childElements(element).map((rule) => mapV3OutcomeRule(rule));
1684
+ return { kind: "outcomeProcessingFragment", ...(rules.length ? { rules } : {}) };
1685
+ }
1686
+ default:
1687
+ throw new Error(`Unsupported QTI 3.0.1 outcome rule <${element.localName}> in normalization.`);
1688
+ }
1689
+ }
1690
+
1691
+ function mapV3TestFeedback(element: QtiXmlElementNode) {
1692
+ return {
1693
+ kind: "testFeedback",
1694
+ access: requireAttribute(element, "access"),
1695
+ outcomeIdentifier: requireAttribute(element, "outcome-identifier"),
1696
+ showHide: requireAttribute(element, "show-hide"),
1697
+ identifier: requireAttribute(element, "identifier"),
1698
+ ...optionalString(element.attributes, "title", "title"),
1699
+ ...contentOf(element),
1700
+ ...catalogInfoOf(element),
1701
+ };
1702
+ }
1703
+
1704
+ function mapV3TestRubricBlock(element: QtiXmlElementNode) {
1705
+ return {
1706
+ kind: "testRubricBlock",
1707
+ view: attributeList(requireAttribute(element, "view")) ?? [],
1708
+ ...optionalString(element.attributes, "use", "use"),
1709
+ ...contentOf(element),
1710
+ ...catalogInfoOf(element),
1711
+ };
1712
+ }
1713
+
1714
+ /** CAT (§2.8.4): the section delegates selection to an external adaptive engine. */
1715
+ function mapV3AdaptiveSelection(element: QtiXmlElementNode) {
1716
+ const engineRef = firstChildElement(element, "qti-adaptive-engine-ref");
1717
+ if (!engineRef) {
1718
+ throw new Error("<qti-adaptive-selection> must contain <qti-adaptive-engine-ref>.");
1719
+ }
1720
+
1721
+ const adaptiveHref = (refElement: QtiXmlElementNode) => ({
1722
+ identifier: requireAttribute(refElement, "identifier"),
1723
+ href: requireAttribute(refElement, "href"),
1724
+ });
1725
+ const settingsRef = firstChildElement(element, "qti-adaptive-settings-ref");
1726
+ const usagedataRef = firstChildElement(element, "qti-usagedata-ref");
1727
+ const metadataRef = firstChildElement(element, "qti-metadata-ref");
1728
+
1729
+ return {
1730
+ adaptiveEngineRef: adaptiveHref(engineRef),
1731
+ ...(settingsRef ? { adaptiveSettingsRef: adaptiveHref(settingsRef) } : {}),
1732
+ ...(usagedataRef ? { usagedataRef: adaptiveHref(usagedataRef) } : {}),
1733
+ ...(metadataRef ? { metadataRef: adaptiveHref(metadataRef) } : {}),
1734
+ };
1735
+ }
1736
+
1737
+ function mapV3Selection(element: QtiXmlElementNode) {
1738
+ return {
1739
+ select: requireNumberAttribute(element, "select"),
1740
+ ...optionalBoolean(element.attributes, "with-replacement", "withReplacement"),
1741
+ };
1742
+ }
1743
+
1744
+ function mapV3Ordering(element: QtiXmlElementNode) {
1745
+ return {
1746
+ ...optionalBoolean(element.attributes, "shuffle", "shuffle"),
1747
+ };
1748
+ }
1749
+
1750
+ function mapV3AssessmentItemRef(element: QtiXmlElementNode) {
1751
+ return {
1752
+ identifier: requireAttribute(element, "identifier"),
1753
+ href: requireAttribute(element, "href"),
1754
+ ...(attributeBoolean(element.attributes, "required") !== undefined
1755
+ ? { required: attributeBoolean(element.attributes, "required") }
1756
+ : {}),
1757
+ ...(attributeBoolean(element.attributes, "fixed") !== undefined
1758
+ ? { fixed: attributeBoolean(element.attributes, "fixed") }
1759
+ : {}),
1760
+ ...(attributeList(element.attributes["category"])
1761
+ ? { category: attributeList(element.attributes["category"]) }
1762
+ : {}),
1763
+ ...(childElements(element, "qti-pre-condition").length
1764
+ ? { preConditions: childElements(element, "qti-pre-condition").map((child) => mapV3PreCondition(child)) }
1765
+ : {}),
1766
+ ...(childElements(element, "qti-branch-rule").length
1767
+ ? { branchRules: childElements(element, "qti-branch-rule").map((child) => mapV3BranchRule(child)) }
1768
+ : {}),
1769
+ ...(firstChildElement(element, "qti-item-session-control")
1770
+ ? { itemSessionControl: mapV3ItemSessionControl(firstChildElement(element, "qti-item-session-control")!) }
1771
+ : {}),
1772
+ ...(firstChildElement(element, "qti-time-limits")
1773
+ ? { timeLimits: mapV3TimeLimits(firstChildElement(element, "qti-time-limits")!) }
1774
+ : {}),
1775
+ ...(childElements(element, "qti-weight").length
1776
+ ? {
1777
+ weights: childElements(element, "qti-weight").map((weight) => ({
1778
+ identifier: requireAttribute(weight, "identifier"),
1779
+ value: requireNumberAttribute(weight, "value"),
1780
+ })),
1781
+ }
1782
+ : {}),
1783
+ ...(childElements(element, "qti-variable-mapping").length
1784
+ ? {
1785
+ variableMappings: childElements(element, "qti-variable-mapping").map((mapping) => ({
1786
+ sourceIdentifier: requireAttribute(mapping, "source-identifier"),
1787
+ targetIdentifier: requireAttribute(mapping, "target-identifier"),
1788
+ })),
1789
+ }
1790
+ : {}),
1791
+ ...(childElements(element, "qti-template-default").length
1792
+ ? {
1793
+ templateDefaults: childElements(element, "qti-template-default").map((templateDefault) => {
1794
+ const expressionElement = childElements(templateDefault)[0];
1795
+ if (!expressionElement) {
1796
+ throw new Error("<qti-template-default> must contain an expression.");
1797
+ }
1798
+
1799
+ return {
1800
+ kind: "templateDefault",
1801
+ templateIdentifier: requireAttribute(templateDefault, "template-identifier"),
1802
+ expression: mapV3Expression(expressionElement),
1803
+ };
1804
+ }),
1805
+ }
1806
+ : {}),
1807
+ };
1808
+ }
1809
+
1810
+ function mapV3AssessmentSection(element: QtiXmlElementNode): unknown {
1811
+ const children = childElements(element).flatMap((child) => {
1812
+ switch (child.localName) {
1813
+ case "qti-assessment-item-ref":
1814
+ return [mapV3AssessmentItemRef(child)];
1815
+ case "qti-assessment-section":
1816
+ return [mapV3AssessmentSection(child)];
1817
+ case "qti-assessment-section-ref":
1818
+ return [
1819
+ {
1820
+ identifier: requireAttribute(child, "identifier"),
1821
+ href: requireAttribute(child, "href"),
1822
+ },
1823
+ ];
1824
+ case "qti-pre-condition":
1825
+ case "qti-branch-rule":
1826
+ case "qti-item-session-control":
1827
+ case "qti-time-limits":
1828
+ case "qti-selection":
1829
+ case "qti-ordering":
1830
+ case "qti-rubric-block":
1831
+ case "qti-adaptive-selection":
1832
+ return []; // mapped into dedicated fields below
1833
+ default:
1834
+ throw new Error(`Unsupported QTI 3.0.1 assessment section child <${child.localName}> in normalization.`);
1835
+ }
1836
+ });
1837
+
1838
+ return {
1839
+ identifier: requireAttribute(element, "identifier"),
1840
+ title: requireAttribute(element, "title"),
1841
+ visible: attributeBoolean(element.attributes, "visible") ?? true,
1842
+ ...(attributeBoolean(element.attributes, "required") !== undefined
1843
+ ? { required: attributeBoolean(element.attributes, "required") }
1844
+ : {}),
1845
+ ...(attributeBoolean(element.attributes, "fixed") !== undefined
1846
+ ? { fixed: attributeBoolean(element.attributes, "fixed") }
1847
+ : {}),
1848
+ ...(attributeBoolean(element.attributes, "keep-together") !== undefined
1849
+ ? { keepTogether: attributeBoolean(element.attributes, "keep-together") }
1850
+ : {}),
1851
+ ...(childElements(element, "qti-pre-condition").length
1852
+ ? { preConditions: childElements(element, "qti-pre-condition").map((child) => mapV3PreCondition(child)) }
1853
+ : {}),
1854
+ ...(childElements(element, "qti-branch-rule").length
1855
+ ? { branchRules: childElements(element, "qti-branch-rule").map((child) => mapV3BranchRule(child)) }
1856
+ : {}),
1857
+ ...(firstChildElement(element, "qti-item-session-control")
1858
+ ? { itemSessionControl: mapV3ItemSessionControl(firstChildElement(element, "qti-item-session-control")!) }
1859
+ : {}),
1860
+ ...(firstChildElement(element, "qti-time-limits")
1861
+ ? { timeLimits: mapV3TimeLimits(firstChildElement(element, "qti-time-limits")!) }
1862
+ : {}),
1863
+ ...(firstChildElement(element, "qti-adaptive-selection")
1864
+ ? { adaptiveSelection: mapV3AdaptiveSelection(firstChildElement(element, "qti-adaptive-selection")!) }
1865
+ : {}),
1866
+ ...(firstChildElement(element, "qti-selection")
1867
+ ? { selection: mapV3Selection(firstChildElement(element, "qti-selection")!) }
1868
+ : {}),
1869
+ ...(firstChildElement(element, "qti-ordering")
1870
+ ? { ordering: mapV3Ordering(firstChildElement(element, "qti-ordering")!) }
1871
+ : {}),
1872
+ ...(childElements(element, "qti-rubric-block").length
1873
+ ? { rubricBlocks: childElements(element, "qti-rubric-block").map((child) => mapV3TestRubricBlock(child)) }
1874
+ : {}),
1875
+ ...(children.length ? { children } : {}),
1876
+ };
1877
+ }
1878
+
1879
+ function mapV3ResultValues(element: QtiXmlElementNode) {
1880
+ return childElements(element, "value").map((valueElement) => ({
1881
+ value: textContent(valueElement) ?? "",
1882
+ ...optionalString(valueElement.attributes, "fieldIdentifier", "fieldIdentifier"),
1883
+ ...optionalString(valueElement.attributes, "baseType", "baseType"),
1884
+ }));
1885
+ }
1886
+
1887
+ function mapV3ResultResponseVariable(element: QtiXmlElementNode) {
1888
+ const candidateResponseElement = firstChildElement(element, "candidateResponse");
1889
+ const correctResponseElement = firstChildElement(element, "correctResponse");
1890
+
1891
+ return {
1892
+ identifier: requireAttribute(element, "identifier"),
1893
+ cardinality: requireAttribute(element, "cardinality"),
1894
+ baseType: element.attributes["baseType"],
1895
+ candidateResponse: {
1896
+ values: candidateResponseElement ? mapV3ResultValues(candidateResponseElement) : [],
1897
+ },
1898
+ ...(correctResponseElement
1899
+ ? {
1900
+ correctResponse: {
1901
+ values: mapV3ResultValues(correctResponseElement),
1902
+ ...optionalString(correctResponseElement.attributes, "interpretation", "interpretation"),
1903
+ },
1904
+ }
1905
+ : {}),
1906
+ ...(element.attributes["choiceSequence"]
1907
+ ? { choiceSequence: element.attributes["choiceSequence"].split(/\s+/u).filter(Boolean) }
1908
+ : {}),
1909
+ ...(element.attributes["scoreStatus"] ? { scoreStatus: element.attributes["scoreStatus"] } : {}),
1910
+ ...(element.attributes["answeredStatus"] ? { answeredStatus: element.attributes["answeredStatus"] } : {}),
1911
+ };
1912
+ }
1913
+
1914
+ function mapV3ResultOutcomeVariable(element: QtiXmlElementNode) {
1915
+ return {
1916
+ identifier: requireAttribute(element, "identifier"),
1917
+ cardinality: requireAttribute(element, "cardinality"),
1918
+ baseType: element.attributes["baseType"],
1919
+ values: mapV3ResultValues(element),
1920
+ ...(element.attributes["view"] ? { view: element.attributes["view"].split(/\s+/u).filter(Boolean) } : {}),
1921
+ ...optionalString(element.attributes, "interpretation", "interpretation"),
1922
+ ...optionalString(element.attributes, "longInterpretation", "longInterpretation"),
1923
+ ...optionalNumber(element.attributes, "normalMaximum", "normalMaximum"),
1924
+ ...optionalNumber(element.attributes, "normalMinimum", "normalMinimum"),
1925
+ ...(attributeNumber(element.attributes, "masteryValue") !== undefined
1926
+ ? { masteryValue: attributeNumber(element.attributes, "masteryValue") }
1927
+ : {}),
1928
+ ...optionalString(element.attributes, "external-scored", "externalScored"),
1929
+ ...optionalString(element.attributes, "variable-identifier-ref", "variableIdentifierRef"),
1930
+ };
1931
+ }
1932
+
1933
+ function mapV3ResultContextTemplateVariable(element: QtiXmlElementNode) {
1934
+ return {
1935
+ identifier: requireAttribute(element, "identifier"),
1936
+ cardinality: requireAttribute(element, "cardinality"),
1937
+ baseType: element.attributes["baseType"],
1938
+ values: mapV3ResultValues(element),
1939
+ };
1940
+ }
1941
+
1942
+ function mapV3ResultSupport(element: QtiXmlElementNode) {
1943
+ return {
1944
+ name: requireAttribute(element, "name"),
1945
+ assignment: requireAttribute(element, "assignment"),
1946
+ ...optionalString(element.attributes, "value", "value"),
1947
+ ...optionalString(element.attributes, "language", "xmlLang"),
1948
+ };
1949
+ }
1950
+
1951
+ /** The itemVariable/support children shared by testResult and itemResult. */
1952
+ function mapV3ResultVariables(element: QtiXmlElementNode) {
1953
+ return {
1954
+ ...(childElements(element, "responseVariable").length
1955
+ ? {
1956
+ responseVariables: childElements(element, "responseVariable").map((variable) =>
1957
+ mapV3ResultResponseVariable(variable),
1958
+ ),
1959
+ }
1960
+ : {}),
1961
+ ...(childElements(element, "templateVariable").length
1962
+ ? {
1963
+ templateVariables: childElements(element, "templateVariable").map((variable) =>
1964
+ mapV3ResultContextTemplateVariable(variable),
1965
+ ),
1966
+ }
1967
+ : {}),
1968
+ ...(childElements(element, "outcomeVariable").length
1969
+ ? {
1970
+ outcomeVariables: childElements(element, "outcomeVariable").map((variable) =>
1971
+ mapV3ResultOutcomeVariable(variable),
1972
+ ),
1973
+ }
1974
+ : {}),
1975
+ ...(childElements(element, "contextVariable").length
1976
+ ? {
1977
+ contextVariables: childElements(element, "contextVariable").map((variable) =>
1978
+ mapV3ResultContextTemplateVariable(variable),
1979
+ ),
1980
+ }
1981
+ : {}),
1982
+ ...(childElements(element, "support").length
1983
+ ? { supports: childElements(element, "support").map((support) => mapV3ResultSupport(support)) }
1984
+ : {}),
1985
+ };
1986
+ }
1987
+
1988
+ function mapV3ResultContext(element: QtiXmlElementNode) {
1989
+ return {
1990
+ ...(element.attributes["sourcedId"] ? { sourcedId: element.attributes["sourcedId"] } : {}),
1991
+ ...(childElements(element, "sessionIdentifier").length
1992
+ ? {
1993
+ sessionIdentifiers: childElements(element, "sessionIdentifier").map((sessionIdentifier) => ({
1994
+ sourceId: requireAttribute(sessionIdentifier, "sourceID", "sourceId"),
1995
+ identifier: requireAttribute(sessionIdentifier, "identifier"),
1996
+ })),
1997
+ }
1998
+ : {}),
1999
+ };
2000
+ }
2001
+
2002
+ function mapV3TestResult(element: QtiXmlElementNode) {
2003
+ return {
2004
+ identifier: requireAttribute(element, "identifier"),
2005
+ datestamp: requireAttribute(element, "datestamp"),
2006
+ ...mapV3ResultVariables(element),
2007
+ };
2008
+ }
2009
+
2010
+ function mapV3ItemResult(element: QtiXmlElementNode) {
2011
+ const commentElement = firstChildElement(element, "candidateComment");
2012
+
2013
+ return {
2014
+ identifier: requireAttribute(element, "identifier"),
2015
+ datestamp: requireAttribute(element, "datestamp"),
2016
+ sessionStatus: requireAttribute(element, "sessionStatus"),
2017
+ ...(attributeNumber(element.attributes, "sequenceIndex") !== undefined
2018
+ ? { sequenceIndex: attributeNumber(element.attributes, "sequenceIndex") }
2019
+ : {}),
2020
+ ...mapV3ResultVariables(element),
2021
+ ...(commentElement ? { candidateComment: textContent(commentElement) ?? "" } : {}),
2022
+ };
2023
+ }
2024
+
2025
+ function normalizeQti22AssessmentItem(root: QtiXmlElementNode) {
2026
+ return {
2027
+ assessmentItem: {
2028
+ identifier: requireAttribute(root, "identifier"),
2029
+ ...(root.attributes["title"] ? { title: root.attributes["title"] } : {}),
2030
+ ...(attributeBoolean(root.attributes, "adaptive") !== undefined
2031
+ ? { adaptive: attributeBoolean(root.attributes, "adaptive") }
2032
+ : {}),
2033
+ ...(attributeBoolean(root.attributes, "timeDependent") !== undefined
2034
+ ? { timeDependent: attributeBoolean(root.attributes, "timeDependent") }
2035
+ : {}),
2036
+ responseDeclarations: childElements(root, "responseDeclaration").map((element) =>
2037
+ mapV2ResponseDeclaration(element),
2038
+ ),
2039
+ outcomeDeclarations: childElements(root, "outcomeDeclaration").map((element) => mapV2OutcomeDeclaration(element)),
2040
+ ...(firstChildElement(root, "itemBody")
2041
+ ? {
2042
+ itemBody: {
2043
+ children: mapV2ContentNodes(firstChildElement(root, "itemBody")!.children),
2044
+ },
2045
+ }
2046
+ : {}),
2047
+ },
2048
+ };
2049
+ }
2050
+
2051
+ function normalizeQti22Manifest(root: QtiXmlElementNode) {
2052
+ const metadataElement = firstChildElement(root, "metadata");
2053
+ if (!metadataElement) {
2054
+ throw new Error("<manifest> must contain <metadata>.");
2055
+ }
2056
+
2057
+ return {
2058
+ manifest: {
2059
+ identifier: requireAttribute(root, "identifier"),
2060
+ metadata: {
2061
+ schema: textContent(firstChildElement(metadataElement, "schema") ?? metadataElement) ?? "",
2062
+ schemaVersion: textContent(firstChildElement(metadataElement, "schemaversion") ?? metadataElement) ?? "",
2063
+ },
2064
+ organizations: {},
2065
+ resources: childElements(firstChildElement(root, "resources") ?? root, "resource").map((resourceElement) => ({
2066
+ identifier: requireAttribute(resourceElement, "identifier"),
2067
+ type: requireAttribute(resourceElement, "type"),
2068
+ ...(resourceElement.attributes["href"] ? { href: resourceElement.attributes["href"] } : {}),
2069
+ ...(childElements(resourceElement, "file").length
2070
+ ? {
2071
+ files: childElements(resourceElement, "file").map((fileElement) => ({
2072
+ href: requireAttribute(fileElement, "href"),
2073
+ })),
2074
+ }
2075
+ : {}),
2076
+ ...(childElements(resourceElement, "dependency").length
2077
+ ? {
2078
+ dependencies: childElements(resourceElement, "dependency").map((dependencyElement) => ({
2079
+ identifierRef: requireAttribute(dependencyElement, "identifierref"),
2080
+ })),
2081
+ }
2082
+ : {}),
2083
+ })),
2084
+ },
2085
+ };
2086
+ }
2087
+
2088
+ function mapV3ResponseProcessing(element: QtiXmlElementNode) {
2089
+ const rules = childElements(element).map((rule) => mapV3ResponseRule(rule));
2090
+
2091
+ return {
2092
+ ...optionalString(element.attributes, "template", "template"),
2093
+ ...optionalString(element.attributes, "template-location", "templateLocation"),
2094
+ ...(rules.length ? { rules } : {}),
2095
+ };
2096
+ }
2097
+
2098
+ function mapV3ModalFeedback(element: QtiXmlElementNode) {
2099
+ return {
2100
+ kind: "modalFeedback",
2101
+ outcomeIdentifier: requireAttribute(element, "outcome-identifier"),
2102
+ identifier: requireAttribute(element, "identifier"),
2103
+ showHide: requireAttribute(element, "show-hide"),
2104
+ ...optionalString(element.attributes, "title", "title"),
2105
+ ...contentOf(element),
2106
+ ...catalogInfoOf(element),
2107
+ };
2108
+ }
2109
+
2110
+ function normalizeQti301AssessmentItem(root: QtiXmlElementNode) {
2111
+ const templateProcessingElement = firstChildElement(root, "qti-template-processing");
2112
+ const responseProcessingElement = firstChildElement(root, "qti-response-processing");
2113
+
2114
+ return {
2115
+ assessmentItem: {
2116
+ identifier: requireAttribute(root, "identifier"),
2117
+ title: requireAttribute(root, "title"),
2118
+ ...optionalString(root.attributes, "label", "label"),
2119
+ ...optionalString(root.attributes, "xml:lang", "xmlLang"),
2120
+ ...optionalString(root.attributes, "tool-name", "toolName"),
2121
+ ...optionalString(root.attributes, "tool-version", "toolVersion"),
2122
+ timeDependent: attributeBoolean(root.attributes, "time-dependent") ?? false,
2123
+ ...(attributeBoolean(root.attributes, "adaptive") !== undefined
2124
+ ? { adaptive: attributeBoolean(root.attributes, "adaptive") }
2125
+ : {}),
2126
+ ...(childElements(root, "qti-context-declaration").length
2127
+ ? {
2128
+ contextDeclarations: childElements(root, "qti-context-declaration").map((element) =>
2129
+ mapV3ContextDeclaration(element),
2130
+ ),
2131
+ }
2132
+ : {}),
2133
+ responseDeclarations: childElements(root, "qti-response-declaration").map((element) =>
2134
+ mapV3ResponseDeclaration(element),
2135
+ ),
2136
+ outcomeDeclarations: childElements(root, "qti-outcome-declaration").map((element) =>
2137
+ mapV3OutcomeDeclaration(element),
2138
+ ),
2139
+ ...(childElements(root, "qti-template-declaration").length
2140
+ ? {
2141
+ templateDeclarations: childElements(root, "qti-template-declaration").map((element) =>
2142
+ mapV3TemplateDeclaration(element),
2143
+ ),
2144
+ }
2145
+ : {}),
2146
+ ...(templateProcessingElement
2147
+ ? {
2148
+ templateProcessing: {
2149
+ rules: childElements(templateProcessingElement).map((rule) => mapV3TemplateRule(rule)),
2150
+ },
2151
+ }
2152
+ : {}),
2153
+ ...(childElements(root, "qti-assessment-stimulus-ref").length
2154
+ ? {
2155
+ assessmentStimulusRefs: childElements(root, "qti-assessment-stimulus-ref").map((element) => ({
2156
+ identifier: requireAttribute(element, "identifier"),
2157
+ href: requireAttribute(element, "href"),
2158
+ ...optionalString(element.attributes, "title", "title"),
2159
+ })),
2160
+ }
2161
+ : {}),
2162
+ ...companionMaterialsOf(root),
2163
+ ...(childElements(root, "qti-stylesheet").length
2164
+ ? { stylesheets: childElements(root, "qti-stylesheet").map((element) => mapV3StyleSheet(element)) }
2165
+ : {}),
2166
+ ...(firstChildElement(root, "qti-item-body")
2167
+ ? {
2168
+ itemBody: {
2169
+ content: mapV3ContentFragments(firstChildElement(root, "qti-item-body")!.children),
2170
+ },
2171
+ }
2172
+ : {}),
2173
+ ...catalogInfoOf(root),
2174
+ ...(responseProcessingElement ? { responseProcessing: mapV3ResponseProcessing(responseProcessingElement) } : {}),
2175
+ ...(childElements(root, "qti-modal-feedback").length
2176
+ ? { modalFeedbacks: childElements(root, "qti-modal-feedback").map((element) => mapV3ModalFeedback(element)) }
2177
+ : {}),
2178
+ },
2179
+ };
2180
+ }
2181
+
2182
+ function normalizeQti301AssessmentTest(root: QtiXmlElementNode) {
2183
+ const outcomeProcessingElement = firstChildElement(root, "qti-outcome-processing");
2184
+
2185
+ return {
2186
+ assessmentTest: {
2187
+ identifier: requireAttribute(root, "identifier"),
2188
+ title: requireAttribute(root, "title"),
2189
+ ...optionalString(root.attributes, "tool-name", "toolName"),
2190
+ ...optionalString(root.attributes, "tool-version", "toolVersion"),
2191
+ ...(childElements(root, "qti-context-declaration").length
2192
+ ? {
2193
+ contextDeclarations: childElements(root, "qti-context-declaration").map((element) =>
2194
+ mapV3ContextDeclaration(element),
2195
+ ),
2196
+ }
2197
+ : {}),
2198
+ outcomeDeclarations: childElements(root, "qti-outcome-declaration").map((element) =>
2199
+ mapV3OutcomeDeclaration(element),
2200
+ ),
2201
+ ...(firstChildElement(root, "qti-time-limits")
2202
+ ? { timeLimits: mapV3TimeLimits(firstChildElement(root, "qti-time-limits")!) }
2203
+ : {}),
2204
+ ...(childElements(root, "qti-stylesheet").length
2205
+ ? { stylesheets: childElements(root, "qti-stylesheet").map((element) => mapV3StyleSheet(element)) }
2206
+ : {}),
2207
+ ...(childElements(root, "qti-rubric-block").length
2208
+ ? { rubricBlocks: childElements(root, "qti-rubric-block").map((child) => mapV3TestRubricBlock(child)) }
2209
+ : {}),
2210
+ testParts: childElements(root, "qti-test-part").map((testPart) => ({
2211
+ identifier: requireAttribute(testPart, "identifier"),
2212
+ ...optionalString(testPart.attributes, "title", "title"),
2213
+ navigationMode: requireAttribute(testPart, "navigation-mode"),
2214
+ submissionMode: requireAttribute(testPart, "submission-mode"),
2215
+ ...(childElements(testPart, "qti-pre-condition").length
2216
+ ? { preConditions: childElements(testPart, "qti-pre-condition").map((child) => mapV3PreCondition(child)) }
2217
+ : {}),
2218
+ ...(childElements(testPart, "qti-branch-rule").length
2219
+ ? { branchRules: childElements(testPart, "qti-branch-rule").map((child) => mapV3BranchRule(child)) }
2220
+ : {}),
2221
+ ...(firstChildElement(testPart, "qti-item-session-control")
2222
+ ? { itemSessionControl: mapV3ItemSessionControl(firstChildElement(testPart, "qti-item-session-control")!) }
2223
+ : {}),
2224
+ ...(firstChildElement(testPart, "qti-time-limits")
2225
+ ? { timeLimits: mapV3TimeLimits(firstChildElement(testPart, "qti-time-limits")!) }
2226
+ : {}),
2227
+ ...(childElements(testPart, "qti-rubric-block").length
2228
+ ? { rubricBlocks: childElements(testPart, "qti-rubric-block").map((child) => mapV3TestRubricBlock(child)) }
2229
+ : {}),
2230
+ children: childElements(testPart)
2231
+ .filter((child) => child.localName === "qti-assessment-section")
2232
+ .map((section) => mapV3AssessmentSection(section)),
2233
+ ...(childElements(testPart, "qti-test-feedback").length
2234
+ ? { testFeedbacks: childElements(testPart, "qti-test-feedback").map((child) => mapV3TestFeedback(child)) }
2235
+ : {}),
2236
+ })),
2237
+ ...(outcomeProcessingElement
2238
+ ? {
2239
+ outcomeProcessing: {
2240
+ rules: childElements(outcomeProcessingElement).map((rule) => mapV3OutcomeRule(rule)),
2241
+ },
2242
+ }
2243
+ : {}),
2244
+ ...(childElements(root, "qti-test-feedback").length
2245
+ ? { testFeedbacks: childElements(root, "qti-test-feedback").map((child) => mapV3TestFeedback(child)) }
2246
+ : {}),
2247
+ },
2248
+ };
2249
+ }
2250
+
2251
+ function normalizeQti301AssessmentStimulus(root: QtiXmlElementNode) {
2252
+ return {
2253
+ assessmentStimulus: {
2254
+ identifier: requireAttribute(root, "identifier"),
2255
+ title: requireAttribute(root, "title"),
2256
+ ...optionalString(root.attributes, "label", "label"),
2257
+ ...optionalString(root.attributes, "xml:lang", "xmlLang"),
2258
+ ...optionalString(root.attributes, "tool-name", "toolName"),
2259
+ ...optionalString(root.attributes, "tool-version", "toolVersion"),
2260
+ ...(childElements(root, "qti-stylesheet").length
2261
+ ? { stylesheets: childElements(root, "qti-stylesheet").map((element) => mapV3StyleSheet(element)) }
2262
+ : {}),
2263
+ stimulusBody: {
2264
+ content: mapV3ContentFragments(firstChildElement(root, "qti-stimulus-body")?.children ?? []),
2265
+ },
2266
+ ...catalogInfoOf(root),
2267
+ },
2268
+ };
2269
+ }
2270
+
2271
+ function normalizeQti301AssessmentResult(root: QtiXmlElementNode) {
2272
+ return {
2273
+ assessmentResult: {
2274
+ ...(firstChildElement(root, "context")
2275
+ ? { context: mapV3ResultContext(firstChildElement(root, "context")!) }
2276
+ : { context: {} }),
2277
+ ...(firstChildElement(root, "testResult")
2278
+ ? { testResult: mapV3TestResult(firstChildElement(root, "testResult")!) }
2279
+ : {}),
2280
+ ...(childElements(root, "itemResult").length
2281
+ ? { itemResults: childElements(root, "itemResult").map((itemResult) => mapV3ItemResult(itemResult)) }
2282
+ : {}),
2283
+ },
2284
+ };
2285
+ }
2286
+
2287
+ // ---------- Standalone ASI fragment roots (§3 Root Attribute Descriptions) ----------
2288
+
2289
+ /** "qti-response-processing … enables the exchange of best-practice response processing templates." */
2290
+ function normalizeQti301ResponseProcessingDocument(root: QtiXmlElementNode) {
2291
+ return { responseProcessing: mapV3ResponseProcessing(root) };
2292
+ }
2293
+
2294
+ function normalizeQti301OutcomeDeclarationDocument(root: QtiXmlElementNode) {
2295
+ return { outcomeDeclaration: mapV3OutcomeDeclaration(root) };
2296
+ }
2297
+
2298
+ function normalizeQti301OutcomeProcessingDocument(root: QtiXmlElementNode) {
2299
+ return {
2300
+ outcomeProcessing: { rules: childElements(root).map((rule) => mapV3OutcomeRule(rule)) },
2301
+ };
2302
+ }
2303
+
2304
+ /** "The exchange of a single root qti-assessment-section instance is permitted." */
2305
+ function normalizeQti301AssessmentSectionDocument(root: QtiXmlElementNode) {
2306
+ return { assessmentSection: mapV3AssessmentSection(root) };
2307
+ }
2308
+
2309
+ // ---------- QTI metadata (imsqti_metadatav3p0) ----------
2310
+
2311
+ /** The qtiMetadata camelCase binding — standalone documents and inline manifest metadata. */
2312
+ function mapV3QtiMetadata(element: QtiXmlElementNode) {
2313
+ const interactionTypes = childElements(element, "interactionType")
2314
+ .map((child) => textContent(child))
2315
+ .filter((value): value is string => value !== undefined && value !== "");
2316
+ const scoringModes = childElements(element, "scoringMode")
2317
+ .map((child) => textContent(child))
2318
+ .filter((value): value is string => value !== undefined && value !== "");
2319
+ const pciContext = firstChildElement(element, "portableCustomInteractionContext");
2320
+
2321
+ return {
2322
+ ...pnpChildBoolean(element, "itemTemplate", "itemTemplate"),
2323
+ ...pnpChildBoolean(element, "timeDependent", "timeDependent"),
2324
+ ...pnpChildBoolean(element, "composite", "composite"),
2325
+ ...(interactionTypes.length ? { interactionType: interactionTypes } : {}),
2326
+ ...(pciContext
2327
+ ? {
2328
+ portableCustomInteractionContext: {
2329
+ ...pnpChildText(pciContext, "customTypeIdentifier", "customTypeIdentifier"),
2330
+ ...pnpChildText(pciContext, "interactionKind", "interactionKind"),
2331
+ },
2332
+ }
2333
+ : {}),
2334
+ ...pnpChildText(element, "feedbackType", "feedbackType"),
2335
+ ...pnpChildBoolean(element, "solutionAvailable", "solutionAvailable"),
2336
+ ...(scoringModes.length ? { scoringMode: scoringModes } : {}),
2337
+ ...pnpChildText(element, "toolName", "toolName"),
2338
+ ...pnpChildText(element, "toolVersion", "toolVersion"),
2339
+ ...pnpChildText(element, "toolVendor", "toolVendor"),
2340
+ };
2341
+ }
2342
+
2343
+ function normalizeQti301Metadata(root: QtiXmlElementNode) {
2344
+ return { qtiMetadata: mapV3QtiMetadata(root) };
2345
+ }
2346
+
2347
+ // ---------- QTI 3 content-package manifest (imsqtiv3p0_imscpv1p2_v1p0) ----------
2348
+
2349
+ /** Resource/manifest metadata: inline qtiMetadata plus a structurally preserved LOM. */
2350
+ function mapV3ManifestResourceMetadata(element: QtiXmlElementNode) {
2351
+ const qtiMetadata = firstChildElement(element, "qtiMetadata");
2352
+ const lom = firstChildElement(element, "lom");
2353
+
2354
+ return {
2355
+ ...(qtiMetadata ? { qtiMetadata: mapV3QtiMetadata(qtiMetadata) } : {}),
2356
+ ...(lom ? { lom: mapV3XmlNode(lom) } : {}),
2357
+ };
2358
+ }
2359
+
2360
+ function normalizeQti301Manifest(root: QtiXmlElementNode) {
2361
+ const metadataElement = firstChildElement(root, "metadata");
2362
+ if (!metadataElement) {
2363
+ throw new Error("<manifest> must contain <metadata>.");
2364
+ }
2365
+
2366
+ return {
2367
+ manifest: {
2368
+ identifier: requireAttribute(root, "identifier"),
2369
+ metadata: {
2370
+ schema: textContent(firstChildElement(metadataElement, "schema") ?? metadataElement) ?? "",
2371
+ schemaVersion: textContent(firstChildElement(metadataElement, "schemaversion") ?? metadataElement) ?? "",
2372
+ ...mapV3ManifestResourceMetadata(metadataElement),
2373
+ },
2374
+ organizations: {},
2375
+ resources: childElements(firstChildElement(root, "resources") ?? root, "resource").map((resourceElement) => {
2376
+ const resourceMetadata = firstChildElement(resourceElement, "metadata");
2377
+
2378
+ return {
2379
+ identifier: requireAttribute(resourceElement, "identifier"),
2380
+ type: requireAttribute(resourceElement, "type"),
2381
+ ...optionalString(resourceElement.attributes, "href", "href"),
2382
+ ...(resourceMetadata ? { metadata: mapV3ManifestResourceMetadata(resourceMetadata) } : {}),
2383
+ ...(childElements(resourceElement, "file").length
2384
+ ? {
2385
+ files: childElements(resourceElement, "file").map((fileElement) => ({
2386
+ href: requireAttribute(fileElement, "href"),
2387
+ })),
2388
+ }
2389
+ : {}),
2390
+ ...(childElements(resourceElement, "dependency").length
2391
+ ? {
2392
+ dependencies: childElements(resourceElement, "dependency").map((dependencyElement) => ({
2393
+ identifierRef: requireAttribute(dependencyElement, "identifierref"),
2394
+ })),
2395
+ }
2396
+ : {}),
2397
+ };
2398
+ }),
2399
+ },
2400
+ };
2401
+ }
2402
+
2403
+ // ---------- Usage Data & Item Statistics (imsqti_usagedata_v3p0) ----------
2404
+
2405
+ function mapV3UsageTargetObject(element: QtiXmlElementNode) {
2406
+ return {
2407
+ identifier: requireAttribute(element, "identifier"),
2408
+ ...optionalString(element.attributes, "partIdentifier", "partIdentifier"),
2409
+ ...optionalString(element.attributes, "objectType", "objectType"),
2410
+ };
2411
+ }
2412
+
2413
+ function mapV3UsageStatisticBase(element: QtiXmlElementNode) {
2414
+ return {
2415
+ name: requireAttribute(element, "name"),
2416
+ ...optionalString(element.attributes, "glossary", "glossary"),
2417
+ context: requireAttribute(element, "context"),
2418
+ ...optionalNumber(element.attributes, "caseCount", "caseCount"),
2419
+ ...optionalNumber(element.attributes, "stdError", "stdError"),
2420
+ ...optionalNumber(element.attributes, "stdDeviation", "stdDeviation"),
2421
+ ...optionalString(element.attributes, "lastUpdated", "lastUpdated"),
2422
+ targetObjects: childElements(element, "targetObject").map((target) => mapV3UsageTargetObject(target)),
2423
+ };
2424
+ }
2425
+
2426
+ function mapV3UsageStatistic(element: QtiXmlElementNode): unknown {
2427
+ if (element.localName === "ordinaryStatistic") {
2428
+ const value = firstChildElement(element, "value");
2429
+
2430
+ return {
2431
+ kind: "ordinaryStatistic",
2432
+ ...mapV3UsageStatisticBase(element),
2433
+ value: { value: (value ? textContent(value) : undefined) ?? "" },
2434
+ };
2435
+ }
2436
+
2437
+ if (element.localName === "categorizedStatistic") {
2438
+ const mapping = firstChildElement(element, "mapping");
2439
+ if (!mapping) {
2440
+ throw new Error("<categorizedStatistic> must contain a <mapping>.");
2441
+ }
2442
+
2443
+ return {
2444
+ kind: "categorizedStatistic",
2445
+ ...mapV3UsageStatisticBase(element),
2446
+ mapping: {
2447
+ ...optionalNumber(mapping.attributes, "lowerBound", "lowerBound"),
2448
+ ...optionalNumber(mapping.attributes, "upperBound", "upperBound"),
2449
+ ...optionalNumber(mapping.attributes, "defaultValue", "defaultValue"),
2450
+ mapEntries: childElements(mapping, "mapEntry").map((entry) => ({
2451
+ mapKey: requireAttribute(entry, "mapKey"),
2452
+ mappedValue: requireNumberAttribute(entry, "mappedValue"),
2453
+ ...optionalBoolean(entry.attributes, "caseSensitive", "caseSensitive"),
2454
+ })),
2455
+ },
2456
+ };
2457
+ }
2458
+
2459
+ throw new Error(`Unsupported usage data statistic <${element.localName}> in normalization.`);
2460
+ }
2461
+
2462
+ function normalizeQti301UsageData(root: QtiXmlElementNode) {
2463
+ return {
2464
+ usageData: {
2465
+ ...optionalString(root.attributes, "glossary", "glossary"),
2466
+ statistics: childElements(root).map((statistic) => mapV3UsageStatistic(statistic)),
2467
+ },
2468
+ };
2469
+ }
2470
+
2471
+ // ---------- AfA PNP (the QTI 3.0 profile of AfA PNP 3.0, imsqtiv3p0_afa3p0pnp_v1p0) ----------
2472
+
2473
+ const pnpReplaceAccessModePrefix = "replace-access-mode-";
2474
+
2475
+ /** ReplacesAccessModeDType: empty replace-access-mode-* children name the modes replaced. */
2476
+ function pnpReplaceAccessModesOf(element: QtiXmlElementNode): { replaceAccessModes?: string[] } {
2477
+ const modes = childElements(element)
2478
+ .filter((child) => child.localName.startsWith(pnpReplaceAccessModePrefix))
2479
+ .map((child) => child.localName.slice(pnpReplaceAccessModePrefix.length));
2480
+
2481
+ return modes.length ? { replaceAccessModes: modes } : {};
2482
+ }
2483
+
2484
+ function pnpChildText(element: QtiXmlElementNode, name: string, key: string): Record<string, string> {
2485
+ const child = firstChildElement(element, name);
2486
+ const value = child ? textContent(child) : undefined;
2487
+ return value !== undefined && value !== "" ? { [key]: value } : {};
2488
+ }
2489
+
2490
+ function pnpChildNumber(element: QtiXmlElementNode, name: string, key: string): Record<string, number> {
2491
+ const child = firstChildElement(element, name);
2492
+ const value = child ? textContent(child) : undefined;
2493
+ if (value === undefined || value === "") {
2494
+ return {};
2495
+ }
2496
+ const parsed = Number(value);
2497
+ return Number.isNaN(parsed) ? {} : { [key]: parsed };
2498
+ }
2499
+
2500
+ function pnpChildBoolean(element: QtiXmlElementNode, name: string, key: string): Record<string, boolean> {
2501
+ const child = firstChildElement(element, name);
2502
+ const value = child ? textContent(child) : undefined;
2503
+ if (value === "true" || value === "1") {
2504
+ return { [key]: true };
2505
+ }
2506
+ if (value === "false" || value === "0") {
2507
+ return { [key]: false };
2508
+ }
2509
+ return {};
2510
+ }
2511
+
2512
+ /** LanguageModeDType: a ReplacesAccessMode with a required xml:lang. */
2513
+ function mapV3PnpLanguageMode(element: QtiXmlElementNode) {
2514
+ return {
2515
+ ...pnpReplaceAccessModesOf(element),
2516
+ xmlLang: requireAttribute(element, "xml:lang"),
2517
+ };
2518
+ }
2519
+
2520
+ /** FeatureSetDType: empty feature-named children list the controlled features. */
2521
+ function mapV3PnpFeatureSet(element: QtiXmlElementNode) {
2522
+ const features = childElements(element).map((child) => child.localName);
2523
+ return features.length ? { features } : {};
2524
+ }
2525
+
2526
+ function mapV3PnpZoomTarget(element: QtiXmlElementNode) {
2527
+ return optionalNumber(element.attributes, "zoom-amount", "zoomAmount");
2528
+ }
2529
+
2530
+ function mapV3AccessForAllPnp(root: QtiXmlElementNode): Record<string, unknown> {
2531
+ const pnp: Record<string, unknown> = {};
2532
+
2533
+ const feature = (name: string): QtiXmlElementNode | undefined => firstChildElement(root, name);
2534
+ const replaceMode = (name: string, key: string): void => {
2535
+ const element = feature(name);
2536
+ if (element) {
2537
+ pnp[key] = pnpReplaceAccessModesOf(element);
2538
+ }
2539
+ };
2540
+ const languageMode = (name: string, key: string): void => {
2541
+ const element = feature(name);
2542
+ if (element) {
2543
+ pnp[key] = mapV3PnpLanguageMode(element);
2544
+ }
2545
+ };
2546
+ const onScreenFlag = (name: string, key: string): void => {
2547
+ if (feature(name)) {
2548
+ pnp[key] = true;
2549
+ }
2550
+ };
2551
+ const featureSet = (name: string, key: string): void => {
2552
+ const element = feature(name);
2553
+ if (element) {
2554
+ pnp[key] = mapV3PnpFeatureSet(element);
2555
+ }
2556
+ };
2557
+
2558
+ const hazards = childElements(root, "hazard-avoidance")
2559
+ .map((element) => textContent(element))
2560
+ .filter((value): value is string => value !== undefined && value !== "");
2561
+ if (hazards.length) {
2562
+ pnp["hazardAvoidance"] = hazards;
2563
+ }
2564
+
2565
+ const inputRequirementsElement = feature("input-requirements");
2566
+ const inputRequirements = inputRequirementsElement ? textContent(inputRequirementsElement) : undefined;
2567
+ if (inputRequirements) {
2568
+ pnp["inputRequirements"] = inputRequirements;
2569
+ }
2570
+
2571
+ const interfaceLanguages = childElements(root, "language-of-interface").map((element) =>
2572
+ mapV3PnpLanguageMode(element),
2573
+ );
2574
+ if (interfaceLanguages.length) {
2575
+ pnp["languageOfInterface"] = interfaceLanguages;
2576
+ }
2577
+
2578
+ replaceMode("linguistic-guidance", "linguisticGuidance");
2579
+ replaceMode("keyword-emphasis", "keywordEmphasis");
2580
+ languageMode("keyword-translation", "keywordTranslation");
2581
+ replaceMode("simplified-language-portions", "simplifiedLanguagePortions");
2582
+ replaceMode("simplified-graphics", "simplifiedGraphics");
2583
+ languageMode("item-translation", "itemTranslation");
2584
+ languageMode("sign-language", "signLanguage");
2585
+ replaceMode("encouragement", "encouragement");
2586
+
2587
+ const additionalTestingTime = feature("additional-testing-time");
2588
+ if (additionalTestingTime) {
2589
+ pnp["additionalTestingTime"] = {
2590
+ ...pnpReplaceAccessModesOf(additionalTestingTime),
2591
+ ...pnpChildNumber(additionalTestingTime, "time-multiplier", "timeMultiplier"),
2592
+ ...pnpChildNumber(additionalTestingTime, "fixed-minutes", "fixedMinutes"),
2593
+ ...(firstChildElement(additionalTestingTime, "unlimited") ? { unlimited: true } : {}),
2594
+ };
2595
+ }
2596
+
2597
+ const lineReader = feature("line-reader");
2598
+ if (lineReader) {
2599
+ pnp["lineReader"] = {
2600
+ ...pnpReplaceAccessModesOf(lineReader),
2601
+ ...optionalString(lineReader.attributes, "highlight-color", "highlightColor"),
2602
+ };
2603
+ }
2604
+
2605
+ const invertDisplayPolarity = feature("invert-display-polarity");
2606
+ if (invertDisplayPolarity) {
2607
+ pnp["invertDisplayPolarity"] = {
2608
+ ...pnpReplaceAccessModesOf(invertDisplayPolarity),
2609
+ ...optionalString(invertDisplayPolarity.attributes, "foreground", "foreground"),
2610
+ ...optionalString(invertDisplayPolarity.attributes, "background", "background"),
2611
+ };
2612
+ }
2613
+
2614
+ const magnification = feature("magnification");
2615
+ if (magnification) {
2616
+ const allContent = firstChildElement(magnification, "all-content");
2617
+ const text = firstChildElement(magnification, "text");
2618
+ const nonText = firstChildElement(magnification, "non-text");
2619
+ pnp["magnification"] = {
2620
+ ...pnpReplaceAccessModesOf(magnification),
2621
+ ...(allContent ? { allContent: mapV3PnpZoomTarget(allContent) } : {}),
2622
+ ...(text ? { text: mapV3PnpZoomTarget(text) } : {}),
2623
+ ...(nonText ? { nonText: mapV3PnpZoomTarget(nonText) } : {}),
2624
+ };
2625
+ }
2626
+
2627
+ const spoken = feature("spoken");
2628
+ if (spoken) {
2629
+ const restrictionTypes = childElements(spoken, "restriction-type")
2630
+ .map((element) => textContent(element))
2631
+ .filter((value): value is string => value !== undefined && value !== "");
2632
+ pnp["spoken"] = {
2633
+ ...pnpReplaceAccessModesOf(spoken),
2634
+ ...pnpChildText(spoken, "reading-type", "readingType"),
2635
+ ...(restrictionTypes.length ? { restrictionTypes } : {}),
2636
+ ...pnpChildNumber(spoken, "speech-rate", "speechRate"),
2637
+ ...pnpChildNumber(spoken, "pitch", "pitch"),
2638
+ ...pnpChildNumber(spoken, "volume", "volume"),
2639
+ ...pnpChildText(spoken, "link-indication", "linkIndication"),
2640
+ ...pnpChildText(spoken, "typing-echo", "typingEcho"),
2641
+ };
2642
+ }
2643
+
2644
+ replaceMode("tactile", "tactile");
2645
+
2646
+ const braille = feature("braille");
2647
+ if (braille) {
2648
+ pnp["braille"] = {
2649
+ ...pnpReplaceAccessModesOf(braille),
2650
+ ...pnpChildText(braille, "delivery-mode", "deliveryMode"),
2651
+ ...pnpChildText(braille, "grade", "grade"),
2652
+ ...pnpChildText(braille, "braille-type", "brailleType"),
2653
+ ...pnpChildText(braille, "math-type", "mathType"),
2654
+ ...optionalString(braille.attributes, "xml:lang", "xmlLang"),
2655
+ };
2656
+ }
2657
+
2658
+ replaceMode("answer-masking", "answerMasking");
2659
+ replaceMode("keyboard-directions", "keyboardDirections");
2660
+ replaceMode("additional-directions", "additionalDirections");
2661
+
2662
+ const longDescription = feature("long-description");
2663
+ if (longDescription) {
2664
+ pnp["longDescription"] = {
2665
+ ...pnpReplaceAccessModesOf(longDescription),
2666
+ ...optionalBoolean(longDescription.attributes, "hide-visually", "hideVisually"),
2667
+ };
2668
+ }
2669
+
2670
+ replaceMode("captions", "captions");
2671
+
2672
+ const environment = feature("environment");
2673
+ if (environment) {
2674
+ pnp["environment"] = {
2675
+ ...pnpReplaceAccessModesOf(environment),
2676
+ ...pnpChildText(environment, "description", "description"),
2677
+ ...pnpChildText(environment, "medical", "medical"),
2678
+ ...pnpChildText(environment, "software", "software"),
2679
+ ...pnpChildText(environment, "hardware", "hardware"),
2680
+ ...pnpChildBoolean(environment, "breaks", "breaks"),
2681
+ };
2682
+ }
2683
+
2684
+ replaceMode("transcript", "transcript");
2685
+ replaceMode("alternative-text", "alternativeText");
2686
+ replaceMode("audio-description", "audioDescription");
2687
+ replaceMode("high-contrast", "highContrast");
2688
+ replaceMode("layout-single-column", "layoutSingleColumn");
2689
+
2690
+ const textAppearance = feature("text-appearance");
2691
+ if (textAppearance) {
2692
+ const fontFace = firstChildElement(textAppearance, "font-face");
2693
+ const fontNames = fontFace
2694
+ ? childElements(fontFace, "font-name")
2695
+ .map((element) => textContent(element))
2696
+ .filter((value): value is string => value !== undefined && value !== "")
2697
+ : [];
2698
+ pnp["textAppearance"] = {
2699
+ ...pnpReplaceAccessModesOf(textAppearance),
2700
+ ...pnpChildText(textAppearance, "background-color", "backgroundColor"),
2701
+ ...pnpChildText(textAppearance, "font-color", "fontColor"),
2702
+ ...pnpChildNumber(textAppearance, "font-size", "fontSize"),
2703
+ ...(fontFace
2704
+ ? {
2705
+ fontFace: {
2706
+ ...(fontNames.length ? { fontName: fontNames } : {}),
2707
+ ...pnpChildText(fontFace, "generic-font-face", "genericFontFace"),
2708
+ },
2709
+ }
2710
+ : {}),
2711
+ ...pnpChildNumber(textAppearance, "line-spacing", "lineSpacing"),
2712
+ ...pnpChildNumber(textAppearance, "line-height", "lineHeight"),
2713
+ ...pnpChildNumber(textAppearance, "letter-spacing", "letterSpacing"),
2714
+ ...(firstChildElement(textAppearance, "uniform-font-sizing") ? { uniformFontSizing: true } : {}),
2715
+ ...pnpChildNumber(textAppearance, "word-spacing", "wordSpacing"),
2716
+ ...(firstChildElement(textAppearance, "word-wrapping") ? { wordWrapping: true } : {}),
2717
+ };
2718
+ }
2719
+
2720
+ const calculator = feature("calculator-on-screen");
2721
+ if (calculator) {
2722
+ pnp["calculatorOnScreen"] = optionalString(calculator.attributes, "calculator-type", "calculatorType");
2723
+ }
2724
+
2725
+ onScreenFlag("dictionary-on-screen", "dictionaryOnScreen");
2726
+ onScreenFlag("glossary-on-screen", "glossaryOnScreen");
2727
+ onScreenFlag("thesaurus-on-screen", "thesaurusOnScreen");
2728
+ onScreenFlag("homophone-checker-on-screen", "homophoneCheckerOnScreen");
2729
+ onScreenFlag("note-taking-on-screen", "noteTakingOnScreen");
2730
+ onScreenFlag("visual-organizer-on-screen", "visualOrganizerOnScreen");
2731
+ onScreenFlag("outliner-on-screen", "outlinerOnScreen");
2732
+ onScreenFlag("peer-interaction-on-screen", "peerInteractionOnScreen");
2733
+ onScreenFlag("spell-checker-on-screen", "spellCheckerOnScreen");
2734
+
2735
+ featureSet("activate-at-initialization-set", "activateAtInitializationSet");
2736
+ featureSet("activate-as-option-set", "activateAsOptionSet");
2737
+ featureSet("prohibit-set", "prohibitSet");
2738
+
2739
+ return pnp;
2740
+ }
2741
+
2742
+ function normalizeQti301AccessForAllPnp(root: QtiXmlElementNode) {
2743
+ return { accessForAllPnp: mapV3AccessForAllPnp(root) };
2744
+ }
2745
+
2746
+ function normalizeQti301AccessForAllPnpRecords(root: QtiXmlElementNode) {
2747
+ return {
2748
+ accessForAllPnpRecords: {
2749
+ records: childElements(root, "access-for-all-pnp-record").map((record) => {
2750
+ const personSourcedId = firstChildElement(record, "person-sourced-id");
2751
+ if (!personSourcedId) {
2752
+ throw new Error("An access-for-all-pnp-record requires a person-sourced-id.");
2753
+ }
2754
+ const pnp = firstChildElement(record, "access-for-all-pnp");
2755
+ const appointmentIds = childElements(record, "appointment-id")
2756
+ .map((element) => textContent(element))
2757
+ .filter((value): value is string => value !== undefined && value !== "");
2758
+
2759
+ return {
2760
+ personSourcedId: {
2761
+ value: textContent(personSourcedId) ?? "",
2762
+ sourceSystem: requireAttribute(personSourcedId, "source-system"),
2763
+ },
2764
+ ...(appointmentIds.length ? { appointmentId: appointmentIds } : {}),
2765
+ accessForAllPnp: pnp ? mapV3AccessForAllPnp(pnp) : {},
2766
+ };
2767
+ }),
2768
+ },
2769
+ };
2770
+ }
2771
+
2772
+ export function normalizeQtiDocument(
2773
+ version: QtiVersion,
2774
+ schemaSelectionKey: QtiSchemaSelectionKey,
2775
+ root: QtiXmlElementNode,
2776
+ ): unknown {
2777
+ switch (`${version}:${schemaSelectionKey}`) {
2778
+ case "2.2:qtiAssessmentItemDocument":
2779
+ return normalizeQti22AssessmentItem(root);
2780
+ case "2.2:qtiManifestDocument":
2781
+ return normalizeQti22Manifest(root);
2782
+ // The v2.2 usage data binding is structurally identical to the v3 one.
2783
+ case "2.2:qtiUsageDataDocument":
2784
+ return normalizeQti301UsageData(root);
2785
+ case "3.0.1:qtiAssessmentItemDocument":
2786
+ return normalizeQti301AssessmentItem(root);
2787
+ case "3.0.1:qtiAssessmentStimulusDocument":
2788
+ return normalizeQti301AssessmentStimulus(root);
2789
+ case "3.0.1:qtiAssessmentTestDocument":
2790
+ return normalizeQti301AssessmentTest(root);
2791
+ case "3.0.1:qtiAssessmentResultDocument":
2792
+ return normalizeQti301AssessmentResult(root);
2793
+ case "3.0.1:qtiAccessForAllPnpDocument":
2794
+ return normalizeQti301AccessForAllPnp(root);
2795
+ case "3.0.1:qtiAccessForAllPnpRecordsDocument":
2796
+ return normalizeQti301AccessForAllPnpRecords(root);
2797
+ case "3.0.1:qtiAssessmentSectionDocument":
2798
+ return normalizeQti301AssessmentSectionDocument(root);
2799
+ case "3.0.1:qtiManifestDocument":
2800
+ return normalizeQti301Manifest(root);
2801
+ case "3.0.1:qtiMetadataDocument":
2802
+ return normalizeQti301Metadata(root);
2803
+ case "3.0.1:qtiOutcomeDeclarationDocument":
2804
+ return normalizeQti301OutcomeDeclarationDocument(root);
2805
+ case "3.0.1:qtiOutcomeProcessingDocument":
2806
+ return normalizeQti301OutcomeProcessingDocument(root);
2807
+ case "3.0.1:qtiResponseProcessingDocument":
2808
+ return normalizeQti301ResponseProcessingDocument(root);
2809
+ case "3.0.1:qtiUsageDataDocument":
2810
+ return normalizeQti301UsageData(root);
2811
+ default:
2812
+ throw new Error(`Normalization is not implemented for ${version} ${schemaSelectionKey}.`);
2813
+ }
2814
+ }