@longsightgroup/qti3-core 0.10.3 → 0.10.4

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.
Files changed (113) hide show
  1. package/dist/attempt-state.d.ts.map +1 -1
  2. package/dist/attempt-state.js +2 -27
  3. package/dist/attempt-state.js.map +1 -1
  4. package/dist/content-text.d.ts.map +1 -1
  5. package/dist/content-text.js +31 -9
  6. package/dist/content-text.js.map +1 -1
  7. package/dist/index.d.ts +2 -1
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +2 -1
  10. package/dist/index.js.map +1 -1
  11. package/dist/package-xml.d.ts +1 -0
  12. package/dist/package-xml.d.ts.map +1 -1
  13. package/dist/package-xml.js +1 -0
  14. package/dist/package-xml.js.map +1 -1
  15. package/dist/parser-custom-interactions.js +1 -0
  16. package/dist/parser-custom-interactions.js.map +1 -1
  17. package/dist/parser-item-metadata.d.ts.map +1 -1
  18. package/dist/parser-item-metadata.js +1 -0
  19. package/dist/parser-item-metadata.js.map +1 -1
  20. package/dist/parser-values.d.ts +5 -1
  21. package/dist/parser-values.d.ts.map +1 -1
  22. package/dist/parser-values.js +31 -5
  23. package/dist/parser-values.js.map +1 -1
  24. package/dist/parser.d.ts.map +1 -1
  25. package/dist/parser.js +65 -34
  26. package/dist/parser.js.map +1 -1
  27. package/dist/processing-evaluator-comparison.d.ts.map +1 -1
  28. package/dist/processing-evaluator-comparison.js +16 -6
  29. package/dist/processing-evaluator-comparison.js.map +1 -1
  30. package/dist/processing-evaluator-variable.d.ts.map +1 -1
  31. package/dist/processing-evaluator-variable.js +1 -1
  32. package/dist/processing-evaluator-variable.js.map +1 -1
  33. package/dist/processing-mapping.d.ts.map +1 -1
  34. package/dist/processing-mapping.js +15 -7
  35. package/dist/processing-mapping.js.map +1 -1
  36. package/dist/processing-values.d.ts +4 -4
  37. package/dist/processing-values.d.ts.map +1 -1
  38. package/dist/processing-values.js +28 -14
  39. package/dist/processing-values.js.map +1 -1
  40. package/dist/processing-variables.d.ts +3 -1
  41. package/dist/processing-variables.d.ts.map +1 -1
  42. package/dist/processing-variables.js +38 -2
  43. package/dist/processing-variables.js.map +1 -1
  44. package/dist/qti-namespaces.d.ts +7 -0
  45. package/dist/qti-namespaces.d.ts.map +1 -0
  46. package/dist/qti-namespaces.js +7 -0
  47. package/dist/qti-namespaces.js.map +1 -0
  48. package/dist/qti-package-items.d.ts.map +1 -1
  49. package/dist/qti-package-items.js +23 -13
  50. package/dist/qti-package-items.js.map +1 -1
  51. package/dist/qti-package-manifest.d.ts.map +1 -1
  52. package/dist/qti-package-manifest.js +4 -3
  53. package/dist/qti-package-manifest.js.map +1 -1
  54. package/dist/qti-package-metadata.d.ts.map +1 -1
  55. package/dist/qti-package-metadata.js +3 -2
  56. package/dist/qti-package-metadata.js.map +1 -1
  57. package/dist/qti-package-xml.d.ts +4 -2
  58. package/dist/qti-package-xml.d.ts.map +1 -1
  59. package/dist/qti-package-xml.js +10 -5
  60. package/dist/qti-package-xml.js.map +1 -1
  61. package/dist/qti-package-zip.d.ts +17 -1
  62. package/dist/qti-package-zip.d.ts.map +1 -1
  63. package/dist/qti-package-zip.js +60 -5
  64. package/dist/qti-package-zip.js.map +1 -1
  65. package/dist/qti-package.d.ts +3 -3
  66. package/dist/qti-package.d.ts.map +1 -1
  67. package/dist/qti-package.js +10 -4
  68. package/dist/qti-package.js.map +1 -1
  69. package/dist/response-validation.d.ts +12 -2
  70. package/dist/response-validation.d.ts.map +1 -1
  71. package/dist/response-validation.js +122 -19
  72. package/dist/response-validation.js.map +1 -1
  73. package/dist/session.js +1 -1
  74. package/dist/session.js.map +1 -1
  75. package/dist/trusted-item-session.d.ts.map +1 -1
  76. package/dist/trusted-item-session.js +5 -3
  77. package/dist/trusted-item-session.js.map +1 -1
  78. package/dist/types.d.ts +2 -0
  79. package/dist/types.d.ts.map +1 -1
  80. package/dist/validation-primitives.d.ts +5 -1
  81. package/dist/validation-primitives.d.ts.map +1 -1
  82. package/dist/validation-primitives.js +40 -2
  83. package/dist/validation-primitives.js.map +1 -1
  84. package/dist/xml.d.ts.map +1 -1
  85. package/dist/xml.js +28 -0
  86. package/dist/xml.js.map +1 -1
  87. package/package.json +1 -1
  88. package/src/attempt-state.ts +2 -26
  89. package/src/content-text.ts +45 -9
  90. package/src/index.ts +3 -0
  91. package/src/package-xml.ts +2 -0
  92. package/src/parser-custom-interactions.ts +1 -0
  93. package/src/parser-item-metadata.ts +1 -0
  94. package/src/parser-values.ts +31 -5
  95. package/src/parser.ts +122 -91
  96. package/src/processing-evaluator-comparison.ts +24 -5
  97. package/src/processing-evaluator-variable.ts +1 -0
  98. package/src/processing-mapping.ts +22 -6
  99. package/src/processing-values.ts +59 -14
  100. package/src/processing-variables.ts +47 -2
  101. package/src/qti-namespaces.ts +8 -0
  102. package/src/qti-package-items.ts +56 -49
  103. package/src/qti-package-manifest.ts +13 -4
  104. package/src/qti-package-metadata.ts +3 -2
  105. package/src/qti-package-xml.ts +16 -3
  106. package/src/qti-package-zip.ts +128 -3
  107. package/src/qti-package.ts +12 -3
  108. package/src/response-validation.ts +185 -20
  109. package/src/session.ts +1 -0
  110. package/src/trusted-item-session.ts +5 -3
  111. package/src/types.ts +2 -0
  112. package/src/validation-primitives.ts +43 -3
  113. package/src/xml.ts +37 -0
@@ -1,5 +1,6 @@
1
1
  import type { QtiChoice, QtiContentNode, QtiSourceLocation } from "./types.js";
2
2
  import { assertNever } from "./assert-never.js";
3
+ import { MATHML_NAMESPACE, QTI_ASI_NAMESPACE } from "./qti-namespaces.js";
3
4
  import type { XmlNode } from "./xml.js";
4
5
 
5
6
  export interface FlatTextFromContentOptions {
@@ -48,16 +49,33 @@ function flatTextFromContentNode(
48
49
  case "element":
49
50
  if (
50
51
  excludeAnnotations &&
52
+ isMathContentNamespace(node.namespaceUri) &&
51
53
  (node.qtiName === "annotation" || node.qtiName === "annotation-xml")
52
54
  ) {
53
55
  return "";
54
56
  }
55
- if (node.qtiName === "math" && node.attributes.alttext) return node.attributes.alttext;
56
- if (node.qtiName === "img" && node.attributes.alt) return node.attributes.alt;
57
- if (node.qtiName === "object" && node.attributes["object-label"]) {
57
+ if (
58
+ node.qtiName === "math" &&
59
+ isMathContentNamespace(node.namespaceUri) &&
60
+ node.attributes.alttext
61
+ ) {
62
+ return node.attributes.alttext;
63
+ }
64
+ if (
65
+ node.qtiName === "img" &&
66
+ isQtiContentNamespace(node.namespaceUri) &&
67
+ node.attributes.alt
68
+ ) {
69
+ return node.attributes.alt;
70
+ }
71
+ if (
72
+ node.qtiName === "object" &&
73
+ isQtiContentNamespace(node.namespaceUri) &&
74
+ node.attributes["object-label"]
75
+ ) {
58
76
  return node.attributes["object-label"];
59
77
  }
60
- if (node.qtiName === "math") {
78
+ if (node.qtiName === "math" && isMathContentNamespace(node.namespaceUri)) {
61
79
  return withVisibleBoundary(
62
80
  node.qtiName,
63
81
  node.children
@@ -88,6 +106,7 @@ function flatMathText(node: QtiContentNode, excludeAnnotations: boolean): string
88
106
  if (
89
107
  node.kind === "element" &&
90
108
  excludeAnnotations &&
109
+ isMathContentNamespace(node.namespaceUri) &&
91
110
  (node.qtiName === "annotation" || node.qtiName === "annotation-xml")
92
111
  ) {
93
112
  return "";
@@ -111,7 +130,7 @@ function visibleTextFromXmlNode(node: XmlNode): string {
111
130
  const accessibleLabel = accessibleXmlLabel(node);
112
131
  const text =
113
132
  accessibleLabel ??
114
- (node.localName === "math"
133
+ (node.localName === "math" && isMathContentNamespace(node.uri)
115
134
  ? flatMathXmlText(node)
116
135
  : node.content
117
136
  .map((entry) => (typeof entry === "string" ? entry : visibleTextFromXmlNode(entry)))
@@ -124,7 +143,8 @@ function flatMathXmlText(node: XmlNode): string {
124
143
  .map((entry) =>
125
144
  typeof entry === "string"
126
145
  ? entry
127
- : entry.localName === "annotation" || entry.localName === "annotation-xml"
146
+ : isMathContentNamespace(entry.uri) &&
147
+ (entry.localName === "annotation" || entry.localName === "annotation-xml")
128
148
  ? ""
129
149
  : flatMathXmlText(entry),
130
150
  )
@@ -133,14 +153,30 @@ function flatMathXmlText(node: XmlNode): string {
133
153
  }
134
154
 
135
155
  function accessibleXmlLabel(node: XmlNode): string | undefined {
136
- if (node.localName === "math" && node.attributes.alttext) return node.attributes.alttext;
137
- if (node.localName === "img" && node.attributes.alt) return node.attributes.alt;
138
- if (node.localName === "object" && node.attributes["object-label"]) {
156
+ if (node.localName === "math" && isMathContentNamespace(node.uri) && node.attributes.alttext) {
157
+ return node.attributes.alttext;
158
+ }
159
+ if (node.localName === "img" && isQtiContentNamespace(node.uri) && node.attributes.alt) {
160
+ return node.attributes.alt;
161
+ }
162
+ if (
163
+ node.localName === "object" &&
164
+ isQtiContentNamespace(node.uri) &&
165
+ node.attributes["object-label"]
166
+ ) {
139
167
  return node.attributes["object-label"];
140
168
  }
141
169
  return undefined;
142
170
  }
143
171
 
172
+ function isQtiContentNamespace(namespaceUri: string | undefined): boolean {
173
+ return namespaceUri === undefined || namespaceUri === QTI_ASI_NAMESPACE;
174
+ }
175
+
176
+ function isMathContentNamespace(namespaceUri: string | undefined): boolean {
177
+ return isQtiContentNamespace(namespaceUri) || namespaceUri === MATHML_NAMESPACE;
178
+ }
179
+
144
180
  function withVisibleBoundary(qtiName: string, text: string): string {
145
181
  return visibleBoundaryNames.has(qtiName) ? ` ${text} ` : text;
146
182
  }
package/src/index.ts CHANGED
@@ -52,6 +52,7 @@ export {
52
52
  } from "./package-xml.js";
53
53
  export {
54
54
  decodeUtf8,
55
+ DEFAULT_QTI_PACKAGE_RESOURCE_LIMITS,
55
56
  discoverQtiPackageContentAssets,
56
57
  normalizePackagePath,
57
58
  QTI_ITEM_RESOURCE_TYPE,
@@ -74,6 +75,7 @@ export {
74
75
  type QtiPackageItemSource,
75
76
  type QtiPackageParseOptions,
76
77
  type QtiPackageParseResult,
78
+ type QtiPackageResourceLimits,
77
79
  type QtiPackageShape,
78
80
  type QtiStandardAlignment,
79
81
  type QtiTestPartNavigationMode,
@@ -85,6 +87,7 @@ export {
85
87
  export { detectPackageMediaType } from "./qti-package-assets.js";
86
88
  export { parseQtiXml } from "./parser.js";
87
89
  export { numericTuple3, numericTuple4, parseXmlBoolean } from "./parser-values.js";
90
+ export { MATHML_NAMESPACE, QTI_ASI_NAMESPACE } from "./qti-namespaces.js";
88
91
  export { escapeXmlAttribute, escapeXmlText } from "./xml.js";
89
92
  export {
90
93
  applyQtiMediaPlaybackEvent,
@@ -2,6 +2,7 @@ import { parseXmlTree, type XmlNode } from "./xml.js";
2
2
 
3
3
  export interface QtiPackageXmlNode {
4
4
  localName: string;
5
+ uri?: string | undefined;
5
6
  attributes: Record<string, string>;
6
7
  children: QtiPackageXmlNode[];
7
8
  text: string;
@@ -23,6 +24,7 @@ export function parseQtiPackageXmlTree(xml: string): QtiPackageXmlTree {
23
24
  function packageXmlNode(node: XmlNode): QtiPackageXmlNode {
24
25
  return {
25
26
  localName: node.localName,
27
+ uri: node.uri,
26
28
  attributes: node.attributes,
27
29
  children: node.children.map(packageXmlNode),
28
30
  text: node.text,
@@ -242,6 +242,7 @@ function parseCustomMarkupNode(
242
242
  return {
243
243
  kind: "element",
244
244
  qtiName: node.localName,
245
+ namespaceUri: node.uri,
245
246
  attributes: node.attributes,
246
247
  children: parseCustomMarkupChildren(node, diagnostics, isInteractionElement, options),
247
248
  source: node.source,
@@ -137,6 +137,7 @@ function parseCatalogHtmlChildren(node: XmlNode): QtiContentNode[] {
137
137
  content.push({
138
138
  kind: "element",
139
139
  qtiName: entry.localName,
140
+ namespaceUri: entry.uri,
140
141
  attributes: entry.attributes,
141
142
  children: parseCatalogHtmlChildren(entry),
142
143
  source: entry.source,
@@ -20,6 +20,7 @@ const QTI_BASE_TYPES = new Set<string>([
20
20
  "file",
21
21
  "uri",
22
22
  ]);
23
+ const QTI_FINITE_FLOAT_PATTERN = /^[+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[Ee][+-]?[0-9]+)?$/;
23
24
 
24
25
  export function isQtiBaseType(value: string): value is QtiBaseType {
25
26
  return QTI_BASE_TYPES.has(value);
@@ -43,8 +44,11 @@ export function numericTuple4(values: number[]): [number, number, number, number
43
44
  }
44
45
 
45
46
  export function coerceValue(value: string, baseType: string | undefined): QtiScalarValue {
46
- if (baseType === "integer") return Number.parseInt(value, 10);
47
- if (baseType === "float") return Number.parseFloat(value);
47
+ if (baseType === "integer") return parseInteger(value) ?? value;
48
+ if (baseType === "float") return parseFiniteNumber(value) ?? value;
49
+ if (baseType === "pair" || baseType === "directedPair") {
50
+ return parseQtiPair(value, baseType) ?? value;
51
+ }
48
52
  if (baseType === "boolean") {
49
53
  const parsed = parseXmlBoolean(value);
50
54
  if (parsed !== undefined) return parsed;
@@ -53,6 +57,26 @@ export function coerceValue(value: string, baseType: string | undefined): QtiSca
53
57
  return value;
54
58
  }
55
59
 
60
+ /** Parse and canonicalize a QTI pair while preserving directed-pair endpoint order. */
61
+ export function parseQtiPair(
62
+ value: string | undefined,
63
+ baseType: "pair" | "directedPair",
64
+ ): string | undefined {
65
+ if (value === undefined) return undefined;
66
+ const parts = value.trim().split(/\s+/);
67
+ if (parts.length !== 2 || parts.some((part) => part.length === 0)) return undefined;
68
+ return (baseType === "pair" ? parts.toSorted() : parts).join(" ");
69
+ }
70
+
71
+ /** Parse a complete integer lexical value without accepting a numeric prefix. */
72
+ export function parseInteger(value: string | undefined): number | undefined {
73
+ if (value === undefined) return undefined;
74
+ const normalized = value.trim();
75
+ if (!/^-?\d+$/.test(normalized)) return undefined;
76
+ const parsed = Number(normalized);
77
+ return Number.isInteger(parsed) ? parsed : undefined;
78
+ }
79
+
56
80
  export function parseCardinality(value: string | undefined): QtiCardinality {
57
81
  if (value === "multiple" || value === "ordered" || value === "record") return value;
58
82
  return "single";
@@ -66,10 +90,12 @@ export function parseXmlBoolean(value: string | undefined): boolean | undefined
66
90
  return undefined;
67
91
  }
68
92
 
69
- /** Parse a non-empty finite numeric XML attribute value. */
93
+ /** Parse the finite subset of the XML Schema float lexical space used by QTI values. */
70
94
  export function parseFiniteNumber(value: string | undefined): number | undefined {
71
- if (value === undefined || value.trim().length === 0) return undefined;
72
- const parsed = Number(value);
95
+ if (value === undefined) return undefined;
96
+ const normalized = value.trim();
97
+ if (!QTI_FINITE_FLOAT_PATTERN.test(normalized)) return undefined;
98
+ const parsed = Number(normalized);
73
99
  return Number.isFinite(parsed) ? parsed : undefined;
74
100
  }
75
101
 
package/src/parser.ts CHANGED
@@ -36,9 +36,9 @@ import type {
36
36
  QtiResponseDeclaration,
37
37
  } from "./types.js";
38
38
  import { validateAssessmentItem } from "./validation.js";
39
+ import { QTI_ASI_NAMESPACE } from "./qti-namespaces.js";
39
40
  import { childElements, descendants, parseXmlTree, type XmlNode } from "./xml.js";
40
41
 
41
- const qtiAssessmentItemNamespace = "http://www.imsglobal.org/xsd/imsqtiasi_v3p0";
42
42
  const supportedProcessingNames = new Set(processingSupport.map((entry) => entry.qtiName));
43
43
  const processingContainerNames = new Set(["qti-template-processing", "qti-response-processing"]);
44
44
  const responseProcessingForbiddenNames = new Set([
@@ -78,7 +78,7 @@ export function parseQtiXml(xml: string): QtiParseResult {
78
78
  }
79
79
 
80
80
  const itemNode =
81
- tree.root.localName === "qti-assessment-item" && tree.root.uri === qtiAssessmentItemNamespace
81
+ tree.root.localName === "qti-assessment-item" && tree.root.uri === QTI_ASI_NAMESPACE
82
82
  ? tree.root
83
83
  : undefined;
84
84
  if (!itemNode) {
@@ -87,7 +87,7 @@ export function parseQtiXml(xml: string): QtiParseResult {
87
87
  severity: "error",
88
88
  message:
89
89
  tree.root.localName === "qti-assessment-item"
90
- ? `Expected qti-assessment-item in namespace ${qtiAssessmentItemNamespace}, found ${tree.root.uri ?? "(none)"}.`
90
+ ? `Expected qti-assessment-item in namespace ${QTI_ASI_NAMESPACE}, found ${tree.root.uri ?? "(none)"}.`
91
91
  : `Expected qti-assessment-item root, found ${tree.root.localName}.`,
92
92
  path: tree.root.source.path,
93
93
  source: tree.root.source,
@@ -95,6 +95,23 @@ export function parseQtiXml(xml: string): QtiParseResult {
95
95
  return { ok: false, diagnostics };
96
96
  }
97
97
 
98
+ const foreignQtiElements = descendants(
99
+ itemNode,
100
+ (node) => node.localName.startsWith("qti-") && node.uri !== QTI_ASI_NAMESPACE,
101
+ );
102
+ if (foreignQtiElements.length > 0) {
103
+ for (const node of foreignQtiElements) {
104
+ diagnostics.push({
105
+ code: "qti.namespace",
106
+ severity: "error",
107
+ message: `Expected ${node.localName} in namespace ${QTI_ASI_NAMESPACE}, found ${node.uri ?? "(none)"}.`,
108
+ path: node.source.path,
109
+ source: node.source,
110
+ });
111
+ }
112
+ return { ok: false, diagnostics };
113
+ }
114
+
98
115
  const item = parseAssessmentItem(itemNode, diagnostics);
99
116
  const document: QtiDocument = { item, diagnostics };
100
117
  const validation = validateAssessmentItem(document);
@@ -226,7 +243,18 @@ function diagnoseProcessingElements(
226
243
  }
227
244
 
228
245
  function isInteractionElement(node: XmlNode): boolean {
229
- return interactionNameToType.has(node.localName) || /^qti-.+-interaction$/.test(node.localName);
246
+ return (
247
+ node.uri === QTI_ASI_NAMESPACE &&
248
+ (interactionNameToType.has(node.localName) || /^qti-.+-interaction$/.test(node.localName))
249
+ );
250
+ }
251
+
252
+ function isQtiElement(node: XmlNode, localName?: string): boolean {
253
+ return node.uri === QTI_ASI_NAMESPACE && (!localName || node.localName === localName);
254
+ }
255
+
256
+ function isQtiContentElement(node: XmlNode, localNames: ReadonlySet<string>): boolean {
257
+ return node.uri === QTI_ASI_NAMESPACE && localNames.has(node.localName);
230
258
  }
231
259
 
232
260
  function parseContentChildren(
@@ -265,7 +293,7 @@ function parseContentNode(
265
293
  };
266
294
  }
267
295
 
268
- if (node.localName === "qti-printed-variable") {
296
+ if (isQtiElement(node, "qti-printed-variable")) {
269
297
  return {
270
298
  kind: "printedVariable",
271
299
  identifier: node.attributes.identifier ?? "",
@@ -275,7 +303,7 @@ function parseContentNode(
275
303
  };
276
304
  }
277
305
 
278
- if (node.localName === "qti-feedback-block" || node.localName === "qti-feedback-inline") {
306
+ if (isQtiElement(node, "qti-feedback-block") || isQtiElement(node, "qti-feedback-inline")) {
279
307
  return {
280
308
  kind: "feedback",
281
309
  feedbackType: node.localName === "qti-feedback-block" ? "block" : "inline",
@@ -291,6 +319,7 @@ function parseContentNode(
291
319
  return {
292
320
  kind: "element",
293
321
  qtiName: node.localName,
322
+ namespaceUri: node.uri,
294
323
  attributes: node.attributes,
295
324
  children: parseContentChildren(node, diagnostics, responseDeclarationMap, interactions),
296
325
  source: node.source,
@@ -307,7 +336,7 @@ function parseInteraction(
307
336
  const responseDeclaration = responseIdentifier
308
337
  ? responseDeclarationMap.get(responseIdentifier)
309
338
  : undefined;
310
- const prompt = childElements(node, "qti-prompt")[0];
339
+ const prompt = childElements(node, "qti-prompt").find((child) => isQtiElement(child));
311
340
  const registryStatus = interactionRegistryStatus(node.localName);
312
341
  diagnostics.push(...interactionRegistryDiagnostics(node.localName, node.source));
313
342
 
@@ -318,10 +347,7 @@ function parseInteraction(
318
347
  ? mediaInteractionObject(node)
319
348
  : interactionType === "drawing"
320
349
  ? drawingInteractionObject(node)
321
- : descendants(
322
- node,
323
- (child) => child.localName === "object" || child.localName === "img",
324
- )[0];
350
+ : descendants(node, (child) => isQtiContentElement(child, objectElementNames))[0];
325
351
 
326
352
  const promptContent = prompt
327
353
  ? parseContentChildren(prompt, diagnostics, responseDeclarationMap, [])
@@ -355,10 +381,12 @@ function parseInteraction(
355
381
  interactionType === "gapMatch" || interactionType === "graphicGapMatch"
356
382
  ? parseGapMatchSegments(node)
357
383
  : undefined,
358
- childElements: childElements(node).map((child) => ({
359
- qtiName: child.localName,
360
- source: child.source,
361
- })),
384
+ childElements: childElements(node)
385
+ .filter((child) => isQtiElement(child))
386
+ .map((child) => ({
387
+ qtiName: child.localName,
388
+ source: child.source,
389
+ })),
362
390
  attributes: node.attributes,
363
391
  text: visibleTextContent(node),
364
392
  source: node.source,
@@ -366,40 +394,29 @@ function parseInteraction(
366
394
  }
367
395
 
368
396
  function positionObjectInteractionObject(node: XmlNode): XmlNode | undefined {
369
- return childElements(node).find(
370
- (child) => child.localName === "object" || child.localName === "img",
371
- );
397
+ return childElements(node).find((child) => isQtiContentElement(child, objectElementNames));
372
398
  }
373
399
 
374
400
  function mediaInteractionObject(node: XmlNode): XmlNode | undefined {
375
- return childElements(node).find(
376
- (child) =>
377
- child.localName === "audio" ||
378
- child.localName === "video" ||
379
- child.localName === "object" ||
380
- child.localName === "img",
381
- );
401
+ return childElements(node).find((child) => isQtiContentElement(child, mediaElementNames));
382
402
  }
383
403
 
384
404
  function drawingInteractionObject(node: XmlNode): XmlNode | undefined {
385
- return childElements(node).find(
386
- (child) =>
387
- child.localName === "object" || child.localName === "img" || child.localName === "picture",
388
- );
405
+ return childElements(node).find((child) => isQtiContentElement(child, drawingElementNames));
389
406
  }
390
407
 
391
408
  function positionObjectStageObject(node: XmlNode): XmlNode | undefined {
392
409
  const ancestorStage = nearestAncestor(node, "qti-position-object-stage");
393
- const stage = ancestorStage ?? childElements(node, "qti-position-object-stage")[0];
410
+ const stage =
411
+ ancestorStage ??
412
+ childElements(node, "qti-position-object-stage").find((child) => isQtiElement(child));
394
413
  if (!stage) return undefined;
395
- return childElements(stage).find(
396
- (child) => child.localName === "object" || child.localName === "img",
397
- );
414
+ return childElements(stage).find((child) => isQtiContentElement(child, objectElementNames));
398
415
  }
399
416
 
400
417
  function nearestAncestor(node: XmlNode, localName: string): XmlNode | undefined {
401
418
  for (let parent = node.parent; parent; parent = parent.parent) {
402
- if (parent.localName === localName) return parent;
419
+ if (isQtiElement(parent, localName)) return parent;
403
420
  }
404
421
  return undefined;
405
422
  }
@@ -414,9 +431,9 @@ function parseHottextSegments(node: XmlNode): QtiInteraction["hottextSegments"]
414
431
  return;
415
432
  }
416
433
 
417
- if (entry.localName === "qti-prompt") return;
434
+ if (isQtiElement(entry, "qti-prompt")) return;
418
435
 
419
- if (entry.localName === "qti-hottext") {
436
+ if (isQtiElement(entry, "qti-hottext")) {
420
437
  segments.push({
421
438
  kind: "hottext",
422
439
  identifier: entry.attributes.identifier ?? "",
@@ -428,7 +445,7 @@ function parseHottextSegments(node: XmlNode): QtiInteraction["hottextSegments"]
428
445
  }
429
446
 
430
447
  for (const child of entry.content) visit(child);
431
- if (entry.localName === "p" || entry.localName === "div") {
448
+ if (isQtiContentElement(entry, blockBoundaryElementNames)) {
432
449
  segments.push({ kind: "text", text: " " });
433
450
  }
434
451
  };
@@ -447,11 +464,11 @@ function parseGapMatchSegments(node: XmlNode): QtiInteraction["gapMatchSegments"
447
464
  return;
448
465
  }
449
466
 
450
- if (entry.localName === "qti-prompt") return;
451
- if (entry.localName === "qti-gap-text" || entry.localName === "qti-gap-img") return;
452
- if (entry.localName === "object" || entry.localName === "img") return;
467
+ if (isQtiElement(entry, "qti-prompt")) return;
468
+ if (isQtiElement(entry, "qti-gap-text") || isQtiElement(entry, "qti-gap-img")) return;
469
+ if (isQtiContentElement(entry, objectElementNames)) return;
453
470
 
454
- if (entry.localName === "qti-gap") {
471
+ if (isQtiElement(entry, "qti-gap")) {
455
472
  segments.push({
456
473
  kind: "gap",
457
474
  identifier: entry.attributes.identifier ?? "",
@@ -462,7 +479,7 @@ function parseGapMatchSegments(node: XmlNode): QtiInteraction["gapMatchSegments"
462
479
  }
463
480
 
464
481
  for (const child of entry.content) visit(child);
465
- if (entry.localName === "p" || entry.localName === "div") {
482
+ if (isQtiContentElement(entry, blockBoundaryElementNames)) {
466
483
  segments.push({ kind: "text", text: " " });
467
484
  }
468
485
  };
@@ -491,7 +508,10 @@ function normalizeInlineContext(value: string): string | undefined {
491
508
 
492
509
  function parseObjectAsset(node: XmlNode | undefined): QtiObjectAsset | undefined {
493
510
  if (!node) return undefined;
494
- const pictureImage = node.localName === "picture" ? childElements(node, "img")[0] : undefined;
511
+ const pictureImage =
512
+ node.localName === "picture"
513
+ ? childElements(node, "img").find((child) => isQtiElement(child))
514
+ : undefined;
495
515
  const data = node.attributes.data ?? node.attributes.src ?? pictureImage?.attributes.src;
496
516
  const sources = parseMediaSources(node);
497
517
  const tracks = parseMediaTracks(node);
@@ -599,27 +619,31 @@ function formatDimension(value: number): string {
599
619
  }
600
620
 
601
621
  function parseMediaSources(node: XmlNode): QtiMediaSource[] {
602
- return childElements(node, "source").map((source) => {
603
- const src = source.attributes.src ?? firstSrcsetCandidate(source.attributes.srcset);
604
- return {
605
- src,
606
- type: source.attributes.type ?? assetTypeFromData(src),
607
- attributes: source.attributes,
608
- source: source.source,
609
- };
610
- });
622
+ return childElements(node, "source")
623
+ .filter((source) => isQtiElement(source))
624
+ .map((source) => {
625
+ const src = source.attributes.src ?? firstSrcsetCandidate(source.attributes.srcset);
626
+ return {
627
+ src,
628
+ type: source.attributes.type ?? assetTypeFromData(src),
629
+ attributes: source.attributes,
630
+ source: source.source,
631
+ };
632
+ });
611
633
  }
612
634
 
613
635
  function parseMediaTracks(node: XmlNode): QtiObjectAsset["tracks"] {
614
- return childElements(node, "track").map((track) => ({
615
- kind: track.attributes.kind,
616
- src: track.attributes.src,
617
- srclang: track.attributes.srclang,
618
- label: track.attributes.label,
619
- default: track.attributes.default !== undefined,
620
- attributes: track.attributes,
621
- source: track.source,
622
- }));
636
+ return childElements(node, "track")
637
+ .filter((track) => isQtiElement(track))
638
+ .map((track) => ({
639
+ kind: track.attributes.kind,
640
+ src: track.attributes.src,
641
+ srclang: track.attributes.srclang,
642
+ label: track.attributes.label,
643
+ default: track.attributes.default !== undefined,
644
+ attributes: track.attributes,
645
+ source: track.source,
646
+ }));
623
647
  }
624
648
 
625
649
  function firstSourceType(sources: QtiMediaSource[]): string | undefined {
@@ -666,37 +690,38 @@ function parseChoices(
666
690
  "qti-gap",
667
691
  ]);
668
692
 
669
- return descendants(node, (child) => choiceNames.has(child.localName)).map((choice, index) => {
670
- const identifier = choice.attributes.identifier ?? "";
671
- const asset = parseChoiceAsset(choice);
672
- const content = parseContentChildren(choice, diagnostics, responseDeclarationMap, []);
673
- const flatChoiceText =
674
- content.length > 0
675
- ? flatTextFromContent(content, { excludeAnnotations: true })
676
- : visibleTextContent(choice);
677
- return {
678
- identifier,
679
- text:
680
- flatChoiceText ||
681
- choice.attributes["object-label"] ||
682
- asset?.text ||
683
- identifier ||
684
- `Choice ${index + 1}`,
685
- content: content.length > 0 ? content : undefined,
686
- asset,
687
- role: choiceRole(choice),
688
- qtiName: choice.localName,
689
- attributes: choice.attributes,
690
- source: choice.source,
691
- };
692
- });
693
+ return descendants(node, (child) => isQtiContentElement(child, choiceNames)).map(
694
+ (choice, index) => {
695
+ const identifier = choice.attributes.identifier ?? "";
696
+ const asset = parseChoiceAsset(choice);
697
+ const content = parseContentChildren(choice, diagnostics, responseDeclarationMap, []);
698
+ const flatChoiceText =
699
+ content.length > 0
700
+ ? flatTextFromContent(content, { excludeAnnotations: true })
701
+ : visibleTextContent(choice);
702
+ return {
703
+ identifier,
704
+ text:
705
+ flatChoiceText ||
706
+ choice.attributes["object-label"] ||
707
+ asset?.text ||
708
+ identifier ||
709
+ `Choice ${index + 1}`,
710
+ content: content.length > 0 ? content : undefined,
711
+ asset,
712
+ role: choiceRole(choice),
713
+ qtiName: choice.localName,
714
+ attributes: choice.attributes,
715
+ source: choice.source,
716
+ };
717
+ },
718
+ );
693
719
  }
694
720
 
695
721
  function parseChoiceAsset(choice: XmlNode): QtiObjectAsset | undefined {
696
722
  if (choice.localName !== "qti-gap-img") return undefined;
697
- const assetNode = childElements(choice).find(
698
- (child) =>
699
- child.localName === "img" || child.localName === "object" || child.localName === "picture",
723
+ const assetNode = childElements(choice).find((child) =>
724
+ isQtiContentElement(child, drawingElementNames),
700
725
  );
701
726
  return parseObjectAsset(assetNode);
702
727
  }
@@ -711,7 +736,8 @@ function choiceRole(node: XmlNode): QtiChoiceRole {
711
736
  return "hotspot";
712
737
  }
713
738
  if (node.localName === "qti-simple-associable-choice") {
714
- const matchSet = node.parent?.localName === "qti-simple-match-set" ? node.parent : undefined;
739
+ const matchSet =
740
+ node.parent && isQtiElement(node.parent, "qti-simple-match-set") ? node.parent : undefined;
715
741
  const interaction = nearestInteraction(node);
716
742
  if (matchSet && interaction?.localName === "qti-match-interaction") {
717
743
  return matchSetIndex(matchSet) === 0 ? "matchSource" : "matchTarget";
@@ -723,13 +749,18 @@ function choiceRole(node: XmlNode): QtiChoiceRole {
723
749
 
724
750
  function nearestInteraction(node: XmlNode): XmlNode | undefined {
725
751
  for (let parent = node.parent; parent; parent = parent.parent) {
726
- if (interactionNameToType.has(parent.localName)) return parent;
752
+ if (isQtiElement(parent) && interactionNameToType.has(parent.localName)) return parent;
727
753
  }
728
754
  return undefined;
729
755
  }
730
756
 
731
757
  function matchSetIndex(node: XmlNode): number {
732
758
  const siblings =
733
- node.parent?.children.filter((sibling) => sibling.localName === "qti-simple-match-set") ?? [];
759
+ node.parent?.children.filter((sibling) => isQtiElement(sibling, "qti-simple-match-set")) ?? [];
734
760
  return siblings.indexOf(node);
735
761
  }
762
+
763
+ const objectElementNames = new Set(["object", "img"]);
764
+ const mediaElementNames = new Set(["audio", "video", ...objectElementNames]);
765
+ const drawingElementNames = new Set(["picture", ...objectElementNames]);
766
+ const blockBoundaryElementNames = new Set(["p", "div"]);