@longsightgroup/qti3-core 0.10.2 → 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 (188) hide show
  1. package/README.md +21 -2
  2. package/dist/attempt-state.d.ts.map +1 -1
  3. package/dist/attempt-state.js +20 -27
  4. package/dist/attempt-state.js.map +1 -1
  5. package/dist/content-text.d.ts +4 -0
  6. package/dist/content-text.d.ts.map +1 -1
  7. package/dist/content-text.js +137 -12
  8. package/dist/content-text.js.map +1 -1
  9. package/dist/index.d.ts +6 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +6 -1
  12. package/dist/index.js.map +1 -1
  13. package/dist/media-definition.d.ts +55 -0
  14. package/dist/media-definition.d.ts.map +1 -0
  15. package/dist/media-definition.js +97 -0
  16. package/dist/media-definition.js.map +1 -0
  17. package/dist/package-xml.d.ts +1 -0
  18. package/dist/package-xml.d.ts.map +1 -1
  19. package/dist/package-xml.js +1 -0
  20. package/dist/package-xml.js.map +1 -1
  21. package/dist/parser-custom-interactions.js +1 -0
  22. package/dist/parser-custom-interactions.js.map +1 -1
  23. package/dist/parser-declarations.d.ts +4 -4
  24. package/dist/parser-declarations.d.ts.map +1 -1
  25. package/dist/parser-declarations.js +87 -46
  26. package/dist/parser-declarations.js.map +1 -1
  27. package/dist/parser-item-metadata.d.ts.map +1 -1
  28. package/dist/parser-item-metadata.js +5 -3
  29. package/dist/parser-item-metadata.js.map +1 -1
  30. package/dist/parser-processing.js +10 -9
  31. package/dist/parser-processing.js.map +1 -1
  32. package/dist/parser-values.d.ts +6 -0
  33. package/dist/parser-values.d.ts.map +1 -1
  34. package/dist/parser-values.js +35 -2
  35. package/dist/parser-values.js.map +1 -1
  36. package/dist/parser.d.ts.map +1 -1
  37. package/dist/parser.js +81 -47
  38. package/dist/parser.js.map +1 -1
  39. package/dist/processing-evaluator-collection.d.ts.map +1 -1
  40. package/dist/processing-evaluator-collection.js +8 -2
  41. package/dist/processing-evaluator-collection.js.map +1 -1
  42. package/dist/processing-evaluator-comparison.d.ts.map +1 -1
  43. package/dist/processing-evaluator-comparison.js +22 -6
  44. package/dist/processing-evaluator-comparison.js.map +1 -1
  45. package/dist/processing-evaluator-numeric.d.ts.map +1 -1
  46. package/dist/processing-evaluator-numeric.js +8 -1
  47. package/dist/processing-evaluator-numeric.js.map +1 -1
  48. package/dist/processing-evaluator-variable.d.ts.map +1 -1
  49. package/dist/processing-evaluator-variable.js +1 -1
  50. package/dist/processing-evaluator-variable.js.map +1 -1
  51. package/dist/processing-expression-children.d.ts.map +1 -1
  52. package/dist/processing-expression-children.js +2 -1
  53. package/dist/processing-expression-children.js.map +1 -1
  54. package/dist/processing-limits.d.ts +3 -0
  55. package/dist/processing-limits.d.ts.map +1 -0
  56. package/dist/processing-limits.js +3 -0
  57. package/dist/processing-limits.js.map +1 -0
  58. package/dist/processing-mapping.d.ts.map +1 -1
  59. package/dist/processing-mapping.js +28 -9
  60. package/dist/processing-mapping.js.map +1 -1
  61. package/dist/processing-operators.d.ts +2 -0
  62. package/dist/processing-operators.d.ts.map +1 -1
  63. package/dist/processing-operators.js +34 -5
  64. package/dist/processing-operators.js.map +1 -1
  65. package/dist/processing-values.d.ts +4 -4
  66. package/dist/processing-values.d.ts.map +1 -1
  67. package/dist/processing-values.js +28 -14
  68. package/dist/processing-values.js.map +1 -1
  69. package/dist/processing-variables.d.ts +3 -1
  70. package/dist/processing-variables.d.ts.map +1 -1
  71. package/dist/processing-variables.js +38 -2
  72. package/dist/processing-variables.js.map +1 -1
  73. package/dist/qti-namespaces.d.ts +7 -0
  74. package/dist/qti-namespaces.d.ts.map +1 -0
  75. package/dist/qti-namespaces.js +7 -0
  76. package/dist/qti-namespaces.js.map +1 -0
  77. package/dist/qti-package-items.d.ts.map +1 -1
  78. package/dist/qti-package-items.js +23 -13
  79. package/dist/qti-package-items.js.map +1 -1
  80. package/dist/qti-package-manifest.d.ts.map +1 -1
  81. package/dist/qti-package-manifest.js +4 -3
  82. package/dist/qti-package-manifest.js.map +1 -1
  83. package/dist/qti-package-metadata.d.ts.map +1 -1
  84. package/dist/qti-package-metadata.js +3 -2
  85. package/dist/qti-package-metadata.js.map +1 -1
  86. package/dist/qti-package-xml.d.ts +4 -2
  87. package/dist/qti-package-xml.d.ts.map +1 -1
  88. package/dist/qti-package-xml.js +10 -5
  89. package/dist/qti-package-xml.js.map +1 -1
  90. package/dist/qti-package-zip.d.ts +17 -1
  91. package/dist/qti-package-zip.d.ts.map +1 -1
  92. package/dist/qti-package-zip.js +60 -5
  93. package/dist/qti-package-zip.js.map +1 -1
  94. package/dist/qti-package.d.ts +3 -3
  95. package/dist/qti-package.d.ts.map +1 -1
  96. package/dist/qti-package.js +10 -4
  97. package/dist/qti-package.js.map +1 -1
  98. package/dist/response-validation-policy.d.ts.map +1 -1
  99. package/dist/response-validation-policy.js +5 -6
  100. package/dist/response-validation-policy.js.map +1 -1
  101. package/dist/response-validation.d.ts +12 -2
  102. package/dist/response-validation.d.ts.map +1 -1
  103. package/dist/response-validation.js +122 -19
  104. package/dist/response-validation.js.map +1 -1
  105. package/dist/serializer-processing-expressions.js +1 -1
  106. package/dist/serializer-processing-expressions.js.map +1 -1
  107. package/dist/session.js +1 -1
  108. package/dist/session.js.map +1 -1
  109. package/dist/slider-definition.d.ts +59 -0
  110. package/dist/slider-definition.d.ts.map +1 -0
  111. package/dist/slider-definition.js +198 -0
  112. package/dist/slider-definition.js.map +1 -0
  113. package/dist/support-evidence.d.ts +6 -2
  114. package/dist/support-evidence.d.ts.map +1 -1
  115. package/dist/support-evidence.js +15 -11
  116. package/dist/support-evidence.js.map +1 -1
  117. package/dist/support.d.ts.map +1 -1
  118. package/dist/support.js +96 -100
  119. package/dist/support.js.map +1 -1
  120. package/dist/trusted-item-session.d.ts.map +1 -1
  121. package/dist/trusted-item-session.js +5 -3
  122. package/dist/trusted-item-session.js.map +1 -1
  123. package/dist/types.d.ts +5 -0
  124. package/dist/types.d.ts.map +1 -1
  125. package/dist/validation-declaration-entries.d.ts +13 -0
  126. package/dist/validation-declaration-entries.d.ts.map +1 -0
  127. package/dist/validation-declaration-entries.js +136 -0
  128. package/dist/validation-declaration-entries.js.map +1 -0
  129. package/dist/validation-interactions.d.ts.map +1 -1
  130. package/dist/validation-interactions.js +14 -46
  131. package/dist/validation-interactions.js.map +1 -1
  132. package/dist/validation-primitives.d.ts +5 -1
  133. package/dist/validation-primitives.d.ts.map +1 -1
  134. package/dist/validation-primitives.js +41 -3
  135. package/dist/validation-primitives.js.map +1 -1
  136. package/dist/validation-processing.d.ts.map +1 -1
  137. package/dist/validation-processing.js +125 -1
  138. package/dist/validation-processing.js.map +1 -1
  139. package/dist/validation.d.ts.map +1 -1
  140. package/dist/validation.js +8 -134
  141. package/dist/validation.js.map +1 -1
  142. package/dist/xml.d.ts.map +1 -1
  143. package/dist/xml.js +31 -2
  144. package/dist/xml.js.map +1 -1
  145. package/package.json +1 -1
  146. package/src/attempt-state.ts +22 -26
  147. package/src/content-text.ts +174 -14
  148. package/src/index.ts +28 -0
  149. package/src/media-definition.ts +174 -0
  150. package/src/package-xml.ts +2 -0
  151. package/src/parser-custom-interactions.ts +1 -0
  152. package/src/parser-declarations.ts +134 -40
  153. package/src/parser-item-metadata.ts +5 -3
  154. package/src/parser-processing.ts +10 -9
  155. package/src/parser-values.ts +35 -2
  156. package/src/parser.ts +137 -103
  157. package/src/processing-evaluator-collection.ts +7 -2
  158. package/src/processing-evaluator-comparison.ts +30 -5
  159. package/src/processing-evaluator-numeric.ts +5 -1
  160. package/src/processing-evaluator-variable.ts +1 -0
  161. package/src/processing-expression-children.ts +2 -1
  162. package/src/processing-limits.ts +2 -0
  163. package/src/processing-mapping.ts +32 -8
  164. package/src/processing-operators.ts +38 -5
  165. package/src/processing-values.ts +59 -14
  166. package/src/processing-variables.ts +47 -2
  167. package/src/qti-namespaces.ts +8 -0
  168. package/src/qti-package-items.ts +56 -49
  169. package/src/qti-package-manifest.ts +13 -4
  170. package/src/qti-package-metadata.ts +3 -2
  171. package/src/qti-package-xml.ts +16 -3
  172. package/src/qti-package-zip.ts +128 -3
  173. package/src/qti-package.ts +12 -3
  174. package/src/response-validation-policy.ts +5 -5
  175. package/src/response-validation.ts +185 -20
  176. package/src/serializer-processing-expressions.ts +1 -1
  177. package/src/session.ts +1 -0
  178. package/src/slider-definition.ts +363 -0
  179. package/src/support-evidence.ts +15 -17
  180. package/src/support.ts +106 -102
  181. package/src/trusted-item-session.ts +5 -3
  182. package/src/types.ts +10 -1
  183. package/src/validation-declaration-entries.ts +162 -0
  184. package/src/validation-interactions.ts +14 -64
  185. package/src/validation-primitives.ts +44 -4
  186. package/src/validation-processing.ts +145 -1
  187. package/src/validation.ts +11 -151
  188. package/src/xml.ts +40 -4
package/src/parser.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { appendContentTextNode, flatTextFromContent } from "./content-text.js";
1
+ import { appendContentTextNode, flatTextFromContent, visibleTextContent } from "./content-text.js";
2
2
  import {
3
3
  parseOutcomeDeclaration,
4
4
  parseResponseDeclaration,
@@ -36,9 +36,9 @@ import type {
36
36
  QtiResponseDeclaration,
37
37
  } from "./types.js";
38
38
  import { validateAssessmentItem } from "./validation.js";
39
- import { childElements, descendants, parseXmlTree, textContent, type XmlNode } from "./xml.js";
39
+ import { QTI_ASI_NAMESPACE } from "./qti-namespaces.js";
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);
@@ -109,16 +126,16 @@ export function parseQtiXml(xml: string): QtiParseResult {
109
126
  function parseAssessmentItem(node: XmlNode, diagnostics: QtiDiagnostic[]): QtiAssessmentItem {
110
127
  const identifier = node.attributes.identifier ?? "";
111
128
  const responseDeclarations = childElements(node, "qti-response-declaration").map(
112
- parseResponseDeclaration,
129
+ (declarationNode) => parseResponseDeclaration(declarationNode, diagnostics),
113
130
  );
114
131
  const responseDeclarationMap = new Map(
115
132
  responseDeclarations.map((declaration) => [declaration.identifier, declaration]),
116
133
  );
117
134
  const outcomeDeclarations = childElements(node, "qti-outcome-declaration").map(
118
- parseOutcomeDeclaration,
135
+ (declarationNode) => parseOutcomeDeclaration(declarationNode, diagnostics),
119
136
  );
120
137
  const templateDeclarations = childElements(node, "qti-template-declaration").map(
121
- parseTemplateDeclaration,
138
+ (declarationNode) => parseTemplateDeclaration(declarationNode, diagnostics),
122
139
  );
123
140
  const templateProcessing = parseTemplateProcessing(
124
141
  childElements(node, "qti-template-processing")[0],
@@ -166,7 +183,7 @@ function parseAssessmentItem(node: XmlNode, diagnostics: QtiDiagnostic[]): QtiAs
166
183
  adaptive: parseXmlBoolean(node.attributes.adaptive) === true,
167
184
  timeDependent: parseXmlBoolean(node.attributes["time-dependent"]),
168
185
  attributes: node.attributes,
169
- prompt: prompt ? textContent(prompt) : undefined,
186
+ prompt: prompt ? visibleTextContent(prompt) : undefined,
170
187
  itemBodyAttributes: itemBody?.attributes,
171
188
  itemBodySource: itemBody?.source,
172
189
  responseDeclarations,
@@ -181,7 +198,10 @@ function parseAssessmentItem(node: XmlNode, diagnostics: QtiDiagnostic[]): QtiAs
181
198
  catalogReferences,
182
199
  stylesheets,
183
200
  body,
184
- bodyText: textContent(node),
201
+ bodyText: flatTextFromContent(body, {
202
+ excludeAnnotations: true,
203
+ interactionText: (interactionIndex) => interactions[interactionIndex]?.prompt ?? "",
204
+ }),
185
205
  source: node.source,
186
206
  };
187
207
  }
@@ -223,7 +243,18 @@ function diagnoseProcessingElements(
223
243
  }
224
244
 
225
245
  function isInteractionElement(node: XmlNode): boolean {
226
- 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);
227
258
  }
228
259
 
229
260
  function parseContentChildren(
@@ -262,7 +293,7 @@ function parseContentNode(
262
293
  };
263
294
  }
264
295
 
265
- if (node.localName === "qti-printed-variable") {
296
+ if (isQtiElement(node, "qti-printed-variable")) {
266
297
  return {
267
298
  kind: "printedVariable",
268
299
  identifier: node.attributes.identifier ?? "",
@@ -272,7 +303,7 @@ function parseContentNode(
272
303
  };
273
304
  }
274
305
 
275
- if (node.localName === "qti-feedback-block" || node.localName === "qti-feedback-inline") {
306
+ if (isQtiElement(node, "qti-feedback-block") || isQtiElement(node, "qti-feedback-inline")) {
276
307
  return {
277
308
  kind: "feedback",
278
309
  feedbackType: node.localName === "qti-feedback-block" ? "block" : "inline",
@@ -288,6 +319,7 @@ function parseContentNode(
288
319
  return {
289
320
  kind: "element",
290
321
  qtiName: node.localName,
322
+ namespaceUri: node.uri,
291
323
  attributes: node.attributes,
292
324
  children: parseContentChildren(node, diagnostics, responseDeclarationMap, interactions),
293
325
  source: node.source,
@@ -304,7 +336,7 @@ function parseInteraction(
304
336
  const responseDeclaration = responseIdentifier
305
337
  ? responseDeclarationMap.get(responseIdentifier)
306
338
  : undefined;
307
- const prompt = childElements(node, "qti-prompt")[0];
339
+ const prompt = childElements(node, "qti-prompt").find((child) => isQtiElement(child));
308
340
  const registryStatus = interactionRegistryStatus(node.localName);
309
341
  diagnostics.push(...interactionRegistryDiagnostics(node.localName, node.source));
310
342
 
@@ -315,10 +347,7 @@ function parseInteraction(
315
347
  ? mediaInteractionObject(node)
316
348
  : interactionType === "drawing"
317
349
  ? drawingInteractionObject(node)
318
- : descendants(
319
- node,
320
- (child) => child.localName === "object" || child.localName === "img",
321
- )[0];
350
+ : descendants(node, (child) => isQtiContentElement(child, objectElementNames))[0];
322
351
 
323
352
  const promptContent = prompt
324
353
  ? parseContentChildren(prompt, diagnostics, responseDeclarationMap, [])
@@ -334,7 +363,7 @@ function parseInteraction(
334
363
  prompt: promptContent
335
364
  ? flatTextFromContent(promptContent, { excludeAnnotations: true }) || undefined
336
365
  : prompt
337
- ? textContent(prompt)
366
+ ? visibleTextContent(prompt)
338
367
  : undefined,
339
368
  promptContent: promptContent && promptContent.length > 0 ? promptContent : undefined,
340
369
  promptAttributes: prompt?.attributes,
@@ -352,51 +381,42 @@ function parseInteraction(
352
381
  interactionType === "gapMatch" || interactionType === "graphicGapMatch"
353
382
  ? parseGapMatchSegments(node)
354
383
  : undefined,
355
- childElements: childElements(node).map((child) => ({
356
- qtiName: child.localName,
357
- source: child.source,
358
- })),
384
+ childElements: childElements(node)
385
+ .filter((child) => isQtiElement(child))
386
+ .map((child) => ({
387
+ qtiName: child.localName,
388
+ source: child.source,
389
+ })),
359
390
  attributes: node.attributes,
360
- text: textContent(node),
391
+ text: visibleTextContent(node),
361
392
  source: node.source,
362
393
  };
363
394
  }
364
395
 
365
396
  function positionObjectInteractionObject(node: XmlNode): XmlNode | undefined {
366
- return childElements(node).find(
367
- (child) => child.localName === "object" || child.localName === "img",
368
- );
397
+ return childElements(node).find((child) => isQtiContentElement(child, objectElementNames));
369
398
  }
370
399
 
371
400
  function mediaInteractionObject(node: XmlNode): XmlNode | undefined {
372
- return childElements(node).find(
373
- (child) =>
374
- child.localName === "audio" ||
375
- child.localName === "video" ||
376
- child.localName === "object" ||
377
- child.localName === "img",
378
- );
401
+ return childElements(node).find((child) => isQtiContentElement(child, mediaElementNames));
379
402
  }
380
403
 
381
404
  function drawingInteractionObject(node: XmlNode): XmlNode | undefined {
382
- return childElements(node).find(
383
- (child) =>
384
- child.localName === "object" || child.localName === "img" || child.localName === "picture",
385
- );
405
+ return childElements(node).find((child) => isQtiContentElement(child, drawingElementNames));
386
406
  }
387
407
 
388
408
  function positionObjectStageObject(node: XmlNode): XmlNode | undefined {
389
409
  const ancestorStage = nearestAncestor(node, "qti-position-object-stage");
390
- 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));
391
413
  if (!stage) return undefined;
392
- return childElements(stage).find(
393
- (child) => child.localName === "object" || child.localName === "img",
394
- );
414
+ return childElements(stage).find((child) => isQtiContentElement(child, objectElementNames));
395
415
  }
396
416
 
397
417
  function nearestAncestor(node: XmlNode, localName: string): XmlNode | undefined {
398
418
  for (let parent = node.parent; parent; parent = parent.parent) {
399
- if (parent.localName === localName) return parent;
419
+ if (isQtiElement(parent, localName)) return parent;
400
420
  }
401
421
  return undefined;
402
422
  }
@@ -411,13 +431,13 @@ function parseHottextSegments(node: XmlNode): QtiInteraction["hottextSegments"]
411
431
  return;
412
432
  }
413
433
 
414
- if (entry.localName === "qti-prompt") return;
434
+ if (isQtiElement(entry, "qti-prompt")) return;
415
435
 
416
- if (entry.localName === "qti-hottext") {
436
+ if (isQtiElement(entry, "qti-hottext")) {
417
437
  segments.push({
418
438
  kind: "hottext",
419
439
  identifier: entry.attributes.identifier ?? "",
420
- text: textContent(entry),
440
+ text: visibleTextContent(entry),
421
441
  attributes: entry.attributes,
422
442
  source: entry.source,
423
443
  });
@@ -425,7 +445,7 @@ function parseHottextSegments(node: XmlNode): QtiInteraction["hottextSegments"]
425
445
  }
426
446
 
427
447
  for (const child of entry.content) visit(child);
428
- if (entry.localName === "p" || entry.localName === "div") {
448
+ if (isQtiContentElement(entry, blockBoundaryElementNames)) {
429
449
  segments.push({ kind: "text", text: " " });
430
450
  }
431
451
  };
@@ -444,11 +464,11 @@ function parseGapMatchSegments(node: XmlNode): QtiInteraction["gapMatchSegments"
444
464
  return;
445
465
  }
446
466
 
447
- if (entry.localName === "qti-prompt") return;
448
- if (entry.localName === "qti-gap-text" || entry.localName === "qti-gap-img") return;
449
- 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;
450
470
 
451
- if (entry.localName === "qti-gap") {
471
+ if (isQtiElement(entry, "qti-gap")) {
452
472
  segments.push({
453
473
  kind: "gap",
454
474
  identifier: entry.attributes.identifier ?? "",
@@ -459,7 +479,7 @@ function parseGapMatchSegments(node: XmlNode): QtiInteraction["gapMatchSegments"
459
479
  }
460
480
 
461
481
  for (const child of entry.content) visit(child);
462
- if (entry.localName === "p" || entry.localName === "div") {
482
+ if (isQtiContentElement(entry, blockBoundaryElementNames)) {
463
483
  segments.push({ kind: "text", text: " " });
464
484
  }
465
485
  };
@@ -475,7 +495,7 @@ function inlineInteractionContext(
475
495
  if (interactionType !== "inlineChoice" && interactionType !== "textEntry") return undefined;
476
496
  const parent = node.parent;
477
497
  if (!parent) return undefined;
478
- return normalizeInlineContext(parent.text) ?? normalizeInlineContext(textContent(parent));
498
+ return normalizeInlineContext(parent.text) ?? normalizeInlineContext(visibleTextContent(parent));
479
499
  }
480
500
 
481
501
  function normalizeInlineContext(value: string): string | undefined {
@@ -488,7 +508,10 @@ function normalizeInlineContext(value: string): string | undefined {
488
508
 
489
509
  function parseObjectAsset(node: XmlNode | undefined): QtiObjectAsset | undefined {
490
510
  if (!node) return undefined;
491
- 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;
492
515
  const data = node.attributes.data ?? node.attributes.src ?? pictureImage?.attributes.src;
493
516
  const sources = parseMediaSources(node);
494
517
  const tracks = parseMediaTracks(node);
@@ -505,7 +528,7 @@ function parseObjectAsset(node: XmlNode | undefined): QtiObjectAsset | undefined
505
528
  node.attributes.height ?? pictureImage?.attributes.height ?? inferredSvgDimensions?.height,
506
529
  sources,
507
530
  tracks,
508
- text: textContent(node) || node.attributes.alt || pictureImage?.attributes.alt || "",
531
+ text: visibleTextContent(node) || node.attributes.alt || pictureImage?.attributes.alt || "",
509
532
  attributes: node.attributes,
510
533
  source: node.source,
511
534
  };
@@ -596,27 +619,31 @@ function formatDimension(value: number): string {
596
619
  }
597
620
 
598
621
  function parseMediaSources(node: XmlNode): QtiMediaSource[] {
599
- return childElements(node, "source").map((source) => {
600
- const src = source.attributes.src ?? firstSrcsetCandidate(source.attributes.srcset);
601
- return {
602
- src,
603
- type: source.attributes.type ?? assetTypeFromData(src),
604
- attributes: source.attributes,
605
- source: source.source,
606
- };
607
- });
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
+ });
608
633
  }
609
634
 
610
635
  function parseMediaTracks(node: XmlNode): QtiObjectAsset["tracks"] {
611
- return childElements(node, "track").map((track) => ({
612
- kind: track.attributes.kind,
613
- src: track.attributes.src,
614
- srclang: track.attributes.srclang,
615
- label: track.attributes.label,
616
- default: track.attributes.default !== undefined,
617
- attributes: track.attributes,
618
- source: track.source,
619
- }));
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
+ }));
620
647
  }
621
648
 
622
649
  function firstSourceType(sources: QtiMediaSource[]): string | undefined {
@@ -663,37 +690,38 @@ function parseChoices(
663
690
  "qti-gap",
664
691
  ]);
665
692
 
666
- return descendants(node, (child) => choiceNames.has(child.localName)).map((choice, index) => {
667
- const identifier = choice.attributes.identifier ?? "";
668
- const asset = parseChoiceAsset(choice);
669
- const content = parseContentChildren(choice, diagnostics, responseDeclarationMap, []);
670
- const flatChoiceText =
671
- content.length > 0
672
- ? flatTextFromContent(content, { excludeAnnotations: true })
673
- : textContent(choice);
674
- return {
675
- identifier,
676
- text:
677
- flatChoiceText ||
678
- choice.attributes["object-label"] ||
679
- asset?.text ||
680
- identifier ||
681
- `Choice ${index + 1}`,
682
- content: content.length > 0 ? content : undefined,
683
- asset,
684
- role: choiceRole(choice),
685
- qtiName: choice.localName,
686
- attributes: choice.attributes,
687
- source: choice.source,
688
- };
689
- });
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
+ );
690
719
  }
691
720
 
692
721
  function parseChoiceAsset(choice: XmlNode): QtiObjectAsset | undefined {
693
722
  if (choice.localName !== "qti-gap-img") return undefined;
694
- const assetNode = childElements(choice).find(
695
- (child) =>
696
- child.localName === "img" || child.localName === "object" || child.localName === "picture",
723
+ const assetNode = childElements(choice).find((child) =>
724
+ isQtiContentElement(child, drawingElementNames),
697
725
  );
698
726
  return parseObjectAsset(assetNode);
699
727
  }
@@ -708,7 +736,8 @@ function choiceRole(node: XmlNode): QtiChoiceRole {
708
736
  return "hotspot";
709
737
  }
710
738
  if (node.localName === "qti-simple-associable-choice") {
711
- 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;
712
741
  const interaction = nearestInteraction(node);
713
742
  if (matchSet && interaction?.localName === "qti-match-interaction") {
714
743
  return matchSetIndex(matchSet) === 0 ? "matchSource" : "matchTarget";
@@ -720,13 +749,18 @@ function choiceRole(node: XmlNode): QtiChoiceRole {
720
749
 
721
750
  function nearestInteraction(node: XmlNode): XmlNode | undefined {
722
751
  for (let parent = node.parent; parent; parent = parent.parent) {
723
- if (interactionNameToType.has(parent.localName)) return parent;
752
+ if (isQtiElement(parent) && interactionNameToType.has(parent.localName)) return parent;
724
753
  }
725
754
  return undefined;
726
755
  }
727
756
 
728
757
  function matchSetIndex(node: XmlNode): number {
729
758
  const siblings =
730
- node.parent?.children.filter((sibling) => sibling.localName === "qti-simple-match-set") ?? [];
759
+ node.parent?.children.filter((sibling) => isQtiElement(sibling, "qti-simple-match-set")) ?? [];
731
760
  return siblings.indexOf(node);
732
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"]);
@@ -1,6 +1,7 @@
1
1
  import type { QtiProcessingExpression, QtiScalarValue, QtiValue } from "./types.js";
2
2
  import { assertNever } from "./assert-never.js";
3
3
  import type { EvaluationContext } from "./processing-evaluator.js";
4
+ import { MAX_QTI_REPEAT_RESULT_ELEMENTS } from "./processing-limits.js";
4
5
  import { valueContainer } from "./processing-values.js";
5
6
 
6
7
  type CollectionExpression = Extract<
@@ -39,11 +40,15 @@ export function evaluateRepeatExpression(
39
40
  context: EvaluationContext,
40
41
  ): QtiValue {
41
42
  const repeats = context.indexValue(expression.numberRepeats);
42
- if (repeats === undefined || repeats < 1) return null;
43
+ if (repeats === undefined || repeats < 1 || repeats > MAX_QTI_REPEAT_RESULT_ELEMENTS) {
44
+ return null;
45
+ }
43
46
  const container: QtiScalarValue[] = [];
44
47
  for (let repeat = 0; repeat < repeats; repeat += 1) {
45
48
  for (const item of expression.expressions) {
46
- container.push(...valueContainer(context.evaluate(item)));
49
+ const values = valueContainer(context.evaluate(item));
50
+ if (values.length > MAX_QTI_REPEAT_RESULT_ELEMENTS - container.length) return null;
51
+ for (const value of values) container.push(value);
47
52
  }
48
53
  }
49
54
  return container.length > 0 ? container : null;
@@ -9,7 +9,7 @@ import {
9
9
  valuesEqual,
10
10
  valueContainer,
11
11
  } from "./processing-values.js";
12
- import { expressionIsOrdered } from "./processing-variables.js";
12
+ import { expressionBaseType, expressionIsOrdered } from "./processing-variables.js";
13
13
  import { roundWithMode } from "./processing-operators.js";
14
14
 
15
15
  type ComparisonExpression = Extract<
@@ -40,14 +40,30 @@ export function evaluateComparisonExpression(
40
40
  right,
41
41
  expressionIsOrdered(expression.left, context.document) ||
42
42
  expressionIsOrdered(expression.right, context.document),
43
+ expressionBaseType(expression.left, context.document) ??
44
+ expressionBaseType(expression.right, context.document),
43
45
  );
44
46
  }
45
47
  case "equal": {
46
48
  const left = context.evaluate(expression.left);
47
49
  const right = context.evaluate(expression.right);
48
- return left === null || right === null ? null : valuesEqual(left, right);
50
+ return left === null || right === null
51
+ ? null
52
+ : valuesEqual(
53
+ left,
54
+ right,
55
+ false,
56
+ expressionBaseType(expression.left, context.document) ??
57
+ expressionBaseType(expression.right, context.document),
58
+ );
49
59
  }
50
60
  case "equalRounded": {
61
+ const validRounding =
62
+ Number.isInteger(expression.figures) &&
63
+ (expression.roundingMode === "decimalPlaces"
64
+ ? expression.figures >= 0
65
+ : expression.roundingMode === "significantFigures" && expression.figures > 0);
66
+ if (!validRounding) return null;
51
67
  const left = context.evaluate(expression.left);
52
68
  const right = context.evaluate(expression.right);
53
69
  if (left === null || right === null) return null;
@@ -80,7 +96,10 @@ export function evaluateComparisonExpression(
80
96
  const value = context.evaluate(expression.value);
81
97
  const collection = context.evaluate(expression.collection);
82
98
  if (value === null || collection === null) return null;
83
- return valueContainer(collection).some((item) => valuesEqual(item, value));
99
+ const baseType =
100
+ expressionBaseType(expression.value, context.document) ??
101
+ expressionBaseType(expression.collection, context.document);
102
+ return valueContainer(collection).some((item) => valuesEqual(item, value, false, baseType));
84
103
  }
85
104
  case "delete": {
86
105
  const value = context.evaluate(expression.value);
@@ -88,7 +107,10 @@ export function evaluateComparisonExpression(
88
107
  if (value === null || collectionValue === null) return null;
89
108
  const collection = valueContainer(collectionValue);
90
109
  if (collection.length === 0) return null;
91
- const filtered = collection.filter((item) => !valuesEqual(item, value));
110
+ const baseType =
111
+ expressionBaseType(expression.value, context.document) ??
112
+ expressionBaseType(expression.collection, context.document);
113
+ const filtered = collection.filter((item) => !valuesEqual(item, value, false, baseType));
92
114
  return filtered.length > 0 ? filtered : null;
93
115
  }
94
116
  case "contains": {
@@ -98,7 +120,10 @@ export function evaluateComparisonExpression(
98
120
  const collection = valueContainer(collectionValue);
99
121
  const values = valueContainer(valuesValue);
100
122
  if (collection.length === 0 || values.length === 0) return null;
101
- return containsValues(collection, values);
123
+ const baseType =
124
+ expressionBaseType(expression.collection, context.document) ??
125
+ expressionBaseType(expression.values, context.document);
126
+ return containsValues(collection, values, baseType);
102
127
  }
103
128
  default:
104
129
  return assertNever(expression);
@@ -47,10 +47,12 @@ export function evaluateNumericExpression(
47
47
  ): QtiValue {
48
48
  switch (expression.type) {
49
49
  case "sum": {
50
+ if (expression.expressions.length === 0) return null;
50
51
  const values = context.numericOperands(expression.expressions);
51
52
  return values ? values.reduce((sum, value) => sum + value, 0) : null;
52
53
  }
53
54
  case "product": {
55
+ if (expression.expressions.length === 0) return null;
54
56
  const values = context.numericOperands(expression.expressions);
55
57
  return values ? values.reduce((product, value) => product * value, 1) : null;
56
58
  }
@@ -127,7 +129,9 @@ export function evaluateNumericExpression(
127
129
  return mathOperatorValue(expression.name, values.map(numericValue));
128
130
  }
129
131
  case "statsOperator": {
130
- const value = context.evaluate(expression.expression);
132
+ const expressions = expression.expressions ?? [expression.expression];
133
+ if (expressions.length !== 1) return null;
134
+ const value = context.evaluate(expressions[0]!);
131
135
  if (value === null) return null;
132
136
  return statsOperatorValue(expression.name, valueContainer(value).map(numericValue));
133
137
  }
@@ -37,6 +37,7 @@ export function evaluateVariableExpression(
37
37
  context.responses[expression.identifier] ?? null,
38
38
  context.correctResponses[expression.correctIdentifier] ?? null,
39
39
  declaration.cardinality === "ordered",
40
+ declaration.baseType,
40
41
  )
41
42
  : false;
42
43
  }
@@ -43,8 +43,9 @@ export function expressionChildren(expression: QtiProcessingExpression): QtiProc
43
43
  case "patternMatch":
44
44
  case "fieldValue":
45
45
  case "inside":
46
- case "statsOperator":
47
46
  return [expression.expression];
47
+ case "statsOperator":
48
+ return expression.expressions ?? [expression.expression];
48
49
  case "member":
49
50
  case "delete":
50
51
  return [expression.value, expression.collection];
@@ -0,0 +1,2 @@
1
+ /** Maximum scalar elements a single qti-repeat expression may produce. */
2
+ export const MAX_QTI_REPEAT_RESULT_ELEMENTS = 10_000;