@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.
- package/dist/attempt-state.d.ts.map +1 -1
- package/dist/attempt-state.js +2 -27
- package/dist/attempt-state.js.map +1 -1
- package/dist/content-text.d.ts.map +1 -1
- package/dist/content-text.js +31 -9
- package/dist/content-text.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/package-xml.d.ts +1 -0
- package/dist/package-xml.d.ts.map +1 -1
- package/dist/package-xml.js +1 -0
- package/dist/package-xml.js.map +1 -1
- package/dist/parser-custom-interactions.js +1 -0
- package/dist/parser-custom-interactions.js.map +1 -1
- package/dist/parser-item-metadata.d.ts.map +1 -1
- package/dist/parser-item-metadata.js +1 -0
- package/dist/parser-item-metadata.js.map +1 -1
- package/dist/parser-values.d.ts +5 -1
- package/dist/parser-values.d.ts.map +1 -1
- package/dist/parser-values.js +31 -5
- package/dist/parser-values.js.map +1 -1
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +65 -34
- package/dist/parser.js.map +1 -1
- package/dist/processing-evaluator-comparison.d.ts.map +1 -1
- package/dist/processing-evaluator-comparison.js +16 -6
- package/dist/processing-evaluator-comparison.js.map +1 -1
- package/dist/processing-evaluator-variable.d.ts.map +1 -1
- package/dist/processing-evaluator-variable.js +1 -1
- package/dist/processing-evaluator-variable.js.map +1 -1
- package/dist/processing-mapping.d.ts.map +1 -1
- package/dist/processing-mapping.js +15 -7
- package/dist/processing-mapping.js.map +1 -1
- package/dist/processing-values.d.ts +4 -4
- package/dist/processing-values.d.ts.map +1 -1
- package/dist/processing-values.js +28 -14
- package/dist/processing-values.js.map +1 -1
- package/dist/processing-variables.d.ts +3 -1
- package/dist/processing-variables.d.ts.map +1 -1
- package/dist/processing-variables.js +38 -2
- package/dist/processing-variables.js.map +1 -1
- package/dist/qti-namespaces.d.ts +7 -0
- package/dist/qti-namespaces.d.ts.map +1 -0
- package/dist/qti-namespaces.js +7 -0
- package/dist/qti-namespaces.js.map +1 -0
- package/dist/qti-package-items.d.ts.map +1 -1
- package/dist/qti-package-items.js +23 -13
- package/dist/qti-package-items.js.map +1 -1
- package/dist/qti-package-manifest.d.ts.map +1 -1
- package/dist/qti-package-manifest.js +4 -3
- package/dist/qti-package-manifest.js.map +1 -1
- package/dist/qti-package-metadata.d.ts.map +1 -1
- package/dist/qti-package-metadata.js +3 -2
- package/dist/qti-package-metadata.js.map +1 -1
- package/dist/qti-package-xml.d.ts +4 -2
- package/dist/qti-package-xml.d.ts.map +1 -1
- package/dist/qti-package-xml.js +10 -5
- package/dist/qti-package-xml.js.map +1 -1
- package/dist/qti-package-zip.d.ts +17 -1
- package/dist/qti-package-zip.d.ts.map +1 -1
- package/dist/qti-package-zip.js +60 -5
- package/dist/qti-package-zip.js.map +1 -1
- package/dist/qti-package.d.ts +3 -3
- package/dist/qti-package.d.ts.map +1 -1
- package/dist/qti-package.js +10 -4
- package/dist/qti-package.js.map +1 -1
- package/dist/response-validation.d.ts +12 -2
- package/dist/response-validation.d.ts.map +1 -1
- package/dist/response-validation.js +122 -19
- package/dist/response-validation.js.map +1 -1
- package/dist/session.js +1 -1
- package/dist/session.js.map +1 -1
- package/dist/trusted-item-session.d.ts.map +1 -1
- package/dist/trusted-item-session.js +5 -3
- package/dist/trusted-item-session.js.map +1 -1
- package/dist/types.d.ts +2 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/validation-primitives.d.ts +5 -1
- package/dist/validation-primitives.d.ts.map +1 -1
- package/dist/validation-primitives.js +40 -2
- package/dist/validation-primitives.js.map +1 -1
- package/dist/xml.d.ts.map +1 -1
- package/dist/xml.js +28 -0
- package/dist/xml.js.map +1 -1
- package/package.json +1 -1
- package/src/attempt-state.ts +2 -26
- package/src/content-text.ts +45 -9
- package/src/index.ts +3 -0
- package/src/package-xml.ts +2 -0
- package/src/parser-custom-interactions.ts +1 -0
- package/src/parser-item-metadata.ts +1 -0
- package/src/parser-values.ts +31 -5
- package/src/parser.ts +122 -91
- package/src/processing-evaluator-comparison.ts +24 -5
- package/src/processing-evaluator-variable.ts +1 -0
- package/src/processing-mapping.ts +22 -6
- package/src/processing-values.ts +59 -14
- package/src/processing-variables.ts +47 -2
- package/src/qti-namespaces.ts +8 -0
- package/src/qti-package-items.ts +56 -49
- package/src/qti-package-manifest.ts +13 -4
- package/src/qti-package-metadata.ts +3 -2
- package/src/qti-package-xml.ts +16 -3
- package/src/qti-package-zip.ts +128 -3
- package/src/qti-package.ts +12 -3
- package/src/response-validation.ts +185 -20
- package/src/session.ts +1 -0
- package/src/trusted-item-session.ts +5 -3
- package/src/types.ts +2 -0
- package/src/validation-primitives.ts +43 -3
- package/src/xml.ts +37 -0
package/src/content-text.ts
CHANGED
|
@@ -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 (
|
|
56
|
-
|
|
57
|
-
|
|
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.
|
|
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.
|
|
137
|
-
|
|
138
|
-
|
|
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,
|
package/src/package-xml.ts
CHANGED
|
@@ -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,
|
package/src/parser-values.ts
CHANGED
|
@@ -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
|
|
47
|
-
if (baseType === "float") return
|
|
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
|
|
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
|
|
72
|
-
const
|
|
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 ===
|
|
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 ${
|
|
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
|
|
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
|
|
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
|
|
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")
|
|
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)
|
|
359
|
-
|
|
360
|
-
|
|
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 =
|
|
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
|
|
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
|
|
434
|
+
if (isQtiElement(entry, "qti-prompt")) return;
|
|
418
435
|
|
|
419
|
-
if (entry
|
|
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
|
|
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
|
|
451
|
-
if (entry
|
|
452
|
-
if (entry
|
|
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
|
|
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
|
|
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 =
|
|
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")
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
src
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
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")
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
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) =>
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
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 =
|
|
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
|
|
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"]);
|