@longsightgroup/qti3-core 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/catalog.d.ts +32 -0
- package/dist/catalog.d.ts.map +1 -0
- package/dist/catalog.js +126 -0
- package/dist/catalog.js.map +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +225 -3
- package/dist/parser.js.map +1 -1
- package/dist/session.d.ts +3 -1
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +68 -3
- package/dist/session.js.map +1 -1
- package/dist/support.js +7 -1
- package/dist/support.js.map +1 -1
- package/dist/tts.d.ts +61 -0
- package/dist/tts.d.ts.map +1 -0
- package/dist/tts.js +368 -0
- package/dist/tts.js.map +1 -0
- package/dist/types.d.ts +44 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/validation.d.ts.map +1 -1
- package/dist/validation.js +200 -3
- package/dist/validation.js.map +1 -1
- package/package.json +1 -1
- package/src/catalog.ts +193 -0
- package/src/index.ts +35 -0
- package/src/parser.ts +266 -3
- package/src/session.ts +90 -0
- package/src/support.ts +9 -1
- package/src/tts.ts +555 -0
- package/src/types.ts +52 -0
- package/src/validation.ts +229 -8
package/src/catalog.ts
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
QtiAssessmentItem,
|
|
3
|
+
QtiCatalog,
|
|
4
|
+
QtiCatalogCard,
|
|
5
|
+
QtiCatalogCardEntry,
|
|
6
|
+
QtiCatalogFileHref,
|
|
7
|
+
QtiCatalogHtmlContent,
|
|
8
|
+
QtiDocument,
|
|
9
|
+
QtiSourceLocation,
|
|
10
|
+
} from "./types.js";
|
|
11
|
+
|
|
12
|
+
export interface QtiCatalogSupportResolutionOptions {
|
|
13
|
+
supports?: string | readonly string[] | undefined;
|
|
14
|
+
languages?: string | readonly string[] | undefined;
|
|
15
|
+
includeDefaultFallback?: boolean | undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface QtiCatalogSupportResolution {
|
|
19
|
+
itemIdentifier: string;
|
|
20
|
+
references: QtiResolvedCatalogReference[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface QtiResolvedCatalogReference {
|
|
24
|
+
idref: string;
|
|
25
|
+
catalog?: QtiCatalog | undefined;
|
|
26
|
+
matches: QtiResolvedCatalogSupport[];
|
|
27
|
+
source?: QtiSourceLocation | undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface QtiResolvedCatalogSupport {
|
|
31
|
+
catalogId: string;
|
|
32
|
+
support: string;
|
|
33
|
+
default: boolean;
|
|
34
|
+
fileHrefs: QtiCatalogFileHref[];
|
|
35
|
+
attributes: Record<string, string>;
|
|
36
|
+
cardAttributes: Record<string, string>;
|
|
37
|
+
catalogAttributes: Record<string, string>;
|
|
38
|
+
language?: string | undefined;
|
|
39
|
+
htmlContent?: QtiCatalogHtmlContent | undefined;
|
|
40
|
+
source?: QtiSourceLocation | undefined;
|
|
41
|
+
cardSource?: QtiSourceLocation | undefined;
|
|
42
|
+
catalogSource?: QtiSourceLocation | undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface Candidate {
|
|
46
|
+
card: QtiCatalogCard;
|
|
47
|
+
default: boolean;
|
|
48
|
+
fileHrefs: QtiCatalogFileHref[];
|
|
49
|
+
attributes: Record<string, string>;
|
|
50
|
+
language?: string | undefined;
|
|
51
|
+
htmlContent?: QtiCatalogHtmlContent | undefined;
|
|
52
|
+
source?: QtiSourceLocation | undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function createCatalogSupportResolution(
|
|
56
|
+
model: QtiDocument | QtiAssessmentItem,
|
|
57
|
+
options: QtiCatalogSupportResolutionOptions = {},
|
|
58
|
+
): QtiCatalogSupportResolution {
|
|
59
|
+
const item = "item" in model ? model.item : model;
|
|
60
|
+
const supportFilter = stringFilter(options.supports);
|
|
61
|
+
const languages = stringList(options.languages).map((language) => language.toLowerCase());
|
|
62
|
+
const catalogById = new Map(
|
|
63
|
+
item.catalogInfo?.catalogs.map((catalog) => [catalog.id, catalog] as const) ?? [],
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
itemIdentifier: item.identifier,
|
|
68
|
+
references: item.catalogReferences.map((reference) => {
|
|
69
|
+
const catalog = catalogById.get(reference.idref);
|
|
70
|
+
const resolved: QtiResolvedCatalogReference = {
|
|
71
|
+
idref: reference.idref,
|
|
72
|
+
matches: catalog ? matchingCatalogSupports(catalog, supportFilter, languages, options) : [],
|
|
73
|
+
};
|
|
74
|
+
if (catalog) resolved.catalog = catalog;
|
|
75
|
+
if (reference.source) resolved.source = reference.source;
|
|
76
|
+
return resolved;
|
|
77
|
+
}),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function matchingCatalogSupports(
|
|
82
|
+
catalog: QtiCatalog,
|
|
83
|
+
supportFilter: Set<string> | undefined,
|
|
84
|
+
languages: string[],
|
|
85
|
+
options: QtiCatalogSupportResolutionOptions,
|
|
86
|
+
): QtiResolvedCatalogSupport[] {
|
|
87
|
+
return catalog.cards.flatMap((card) => {
|
|
88
|
+
if (supportFilter && !supportFilter.has(card.support.toLowerCase())) return [];
|
|
89
|
+
return selectedCandidates(card, languages, options).map((candidate) =>
|
|
90
|
+
resolvedSupport(catalog, candidate),
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function selectedCandidates(
|
|
96
|
+
card: QtiCatalogCard,
|
|
97
|
+
languages: string[],
|
|
98
|
+
options: QtiCatalogSupportResolutionOptions,
|
|
99
|
+
): Candidate[] {
|
|
100
|
+
const candidates = catalogCandidates(card);
|
|
101
|
+
if (languages.length === 0) {
|
|
102
|
+
const defaults = candidates.filter((candidate) => candidate.default);
|
|
103
|
+
return defaults.length > 0 ? defaults : candidates;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const languageMatches = candidates
|
|
107
|
+
.map((candidate, index) => ({
|
|
108
|
+
candidate,
|
|
109
|
+
index,
|
|
110
|
+
rank: languageMatchRank(candidate.language, languages),
|
|
111
|
+
}))
|
|
112
|
+
.filter((entry): entry is { candidate: Candidate; index: number; rank: number } =>
|
|
113
|
+
Number.isInteger(entry.rank),
|
|
114
|
+
)
|
|
115
|
+
.sort((a, b) => a.rank - b.rank || a.index - b.index);
|
|
116
|
+
if (languageMatches.length > 0) return languageMatches.map((entry) => entry.candidate);
|
|
117
|
+
if (options.includeDefaultFallback === false) return [];
|
|
118
|
+
const defaults = candidates.filter((candidate) => candidate.default);
|
|
119
|
+
return defaults.length > 0 ? defaults : candidates.filter((candidate) => !candidate.language);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function catalogCandidates(card: QtiCatalogCard): Candidate[] {
|
|
123
|
+
if (card.entries.length > 0) {
|
|
124
|
+
return card.entries.map((entry) => entryCandidate(card, entry));
|
|
125
|
+
}
|
|
126
|
+
return [
|
|
127
|
+
{
|
|
128
|
+
card,
|
|
129
|
+
default: true,
|
|
130
|
+
fileHrefs: card.fileHrefs,
|
|
131
|
+
attributes: card.attributes,
|
|
132
|
+
htmlContent: card.htmlContent,
|
|
133
|
+
source: card.source,
|
|
134
|
+
},
|
|
135
|
+
];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function entryCandidate(card: QtiCatalogCard, entry: QtiCatalogCardEntry): Candidate {
|
|
139
|
+
const candidate: Candidate = {
|
|
140
|
+
card,
|
|
141
|
+
default: entry.default,
|
|
142
|
+
fileHrefs: entry.fileHrefs,
|
|
143
|
+
attributes: entry.attributes,
|
|
144
|
+
};
|
|
145
|
+
if (entry.language) candidate.language = entry.language;
|
|
146
|
+
if (entry.htmlContent) candidate.htmlContent = entry.htmlContent;
|
|
147
|
+
if (entry.source) candidate.source = entry.source;
|
|
148
|
+
return candidate;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function resolvedSupport(catalog: QtiCatalog, candidate: Candidate): QtiResolvedCatalogSupport {
|
|
152
|
+
const resolved: QtiResolvedCatalogSupport = {
|
|
153
|
+
catalogId: catalog.id,
|
|
154
|
+
support: candidate.card.support,
|
|
155
|
+
default: candidate.default,
|
|
156
|
+
fileHrefs: candidate.fileHrefs,
|
|
157
|
+
attributes: candidate.attributes,
|
|
158
|
+
cardAttributes: candidate.card.attributes,
|
|
159
|
+
catalogAttributes: catalog.attributes,
|
|
160
|
+
};
|
|
161
|
+
if (candidate.language) resolved.language = candidate.language;
|
|
162
|
+
if (candidate.htmlContent) resolved.htmlContent = candidate.htmlContent;
|
|
163
|
+
if (candidate.source) resolved.source = candidate.source;
|
|
164
|
+
if (candidate.card.source) resolved.cardSource = candidate.card.source;
|
|
165
|
+
if (catalog.source) resolved.catalogSource = catalog.source;
|
|
166
|
+
return resolved;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function languageMatchRank(
|
|
170
|
+
language: string | undefined,
|
|
171
|
+
requestedLanguages: string[],
|
|
172
|
+
): number | undefined {
|
|
173
|
+
if (!language) return undefined;
|
|
174
|
+
const normalizedLanguage = language.toLowerCase();
|
|
175
|
+
const primaryLanguage = normalizedLanguage.split("-")[0] ?? normalizedLanguage;
|
|
176
|
+
for (const [index, requestedLanguage] of requestedLanguages.entries()) {
|
|
177
|
+
if (normalizedLanguage === requestedLanguage) return index * 2;
|
|
178
|
+
const requestedPrimary = requestedLanguage.split("-")[0] ?? requestedLanguage;
|
|
179
|
+
if (primaryLanguage === requestedPrimary) return index * 2 + 1;
|
|
180
|
+
}
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function stringFilter(value: string | readonly string[] | undefined): Set<string> | undefined {
|
|
185
|
+
const values = stringList(value).map((entry) => entry.toLowerCase());
|
|
186
|
+
return values.length > 0 ? new Set(values) : undefined;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function stringList(value: string | readonly string[] | undefined): string[] {
|
|
190
|
+
if (value === undefined) return [];
|
|
191
|
+
const entries = Array.isArray(value) ? value : [value];
|
|
192
|
+
return entries.map((entry) => entry.trim()).filter((entry) => entry.length > 0);
|
|
193
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,27 @@
|
|
|
1
|
+
export {
|
|
2
|
+
createCatalogSupportResolution,
|
|
3
|
+
type QtiCatalogSupportResolution,
|
|
4
|
+
type QtiCatalogSupportResolutionOptions,
|
|
5
|
+
type QtiResolvedCatalogReference,
|
|
6
|
+
type QtiResolvedCatalogSupport,
|
|
7
|
+
} from "./catalog.js";
|
|
1
8
|
export { parseQtiXml } from "./parser.js";
|
|
9
|
+
export {
|
|
10
|
+
createTextToSpeechTraversal,
|
|
11
|
+
parseQtiDataSsml,
|
|
12
|
+
validateQtiDataSsmlMetadata,
|
|
13
|
+
type QtiDataSsml,
|
|
14
|
+
type QtiDataSsmlBreak,
|
|
15
|
+
type QtiDataSsmlBreakStrength,
|
|
16
|
+
type QtiDataSsmlParseResult,
|
|
17
|
+
type QtiDataSsmlPhoneme,
|
|
18
|
+
type QtiDataSsmlProsody,
|
|
19
|
+
type QtiDataSsmlSayAs,
|
|
20
|
+
type QtiDataSsmlSub,
|
|
21
|
+
type QtiTextToSpeechSegment,
|
|
22
|
+
type QtiTextToSpeechSegmentKind,
|
|
23
|
+
type QtiTextToSpeechTraversal,
|
|
24
|
+
} from "./tts.js";
|
|
2
25
|
export {
|
|
3
26
|
assertQtiAttemptStateV1,
|
|
4
27
|
createItemSession,
|
|
@@ -23,6 +46,13 @@ export type {
|
|
|
23
46
|
QtiAssessmentItem,
|
|
24
47
|
QtiAttemptStatus,
|
|
25
48
|
QtiAttemptStateV1,
|
|
49
|
+
QtiCatalog,
|
|
50
|
+
QtiCatalogCard,
|
|
51
|
+
QtiCatalogCardEntry,
|
|
52
|
+
QtiCatalogFileHref,
|
|
53
|
+
QtiCatalogHtmlContent,
|
|
54
|
+
QtiCatalogInfo,
|
|
55
|
+
QtiCatalogReference,
|
|
26
56
|
QtiChoice,
|
|
27
57
|
QtiChoiceRole,
|
|
28
58
|
QtiContentNode,
|
|
@@ -38,6 +68,11 @@ export type {
|
|
|
38
68
|
QtiObjectAsset,
|
|
39
69
|
QtiParseResult,
|
|
40
70
|
QtiProcessingElementSupport,
|
|
71
|
+
QtiPortableCustomDefinition,
|
|
72
|
+
QtiPortableCustomInteractionModule,
|
|
73
|
+
QtiPortableCustomInteractionModules,
|
|
74
|
+
QtiPortableCustomStateValue,
|
|
75
|
+
QtiPortableCustomVariableBinding,
|
|
41
76
|
QtiResponseBranch,
|
|
42
77
|
QtiScoreResult,
|
|
43
78
|
QtiSupportStatus,
|
package/src/parser.ts
CHANGED
|
@@ -24,6 +24,10 @@ import type {
|
|
|
24
24
|
QtiOutcomeDeclaration,
|
|
25
25
|
QtiParseResult,
|
|
26
26
|
QtiProcessingExpression,
|
|
27
|
+
QtiPortableCustomDefinition,
|
|
28
|
+
QtiPortableCustomInteractionModule,
|
|
29
|
+
QtiPortableCustomInteractionModules,
|
|
30
|
+
QtiPortableCustomVariableBinding,
|
|
27
31
|
QtiRecordValue,
|
|
28
32
|
QtiResponseCondition,
|
|
29
33
|
QtiResponseDeclaration,
|
|
@@ -140,6 +144,7 @@ function parseAssessmentItem(node: XmlNode, diagnostics: QtiDiagnostic[]): QtiAs
|
|
|
140
144
|
return {
|
|
141
145
|
identifier,
|
|
142
146
|
title: node.attributes.title,
|
|
147
|
+
language: node.attributes["xml:lang"] ?? node.attributes.lang,
|
|
143
148
|
adaptive: node.attributes.adaptive === "true",
|
|
144
149
|
prompt: prompt ? textContent(prompt) : undefined,
|
|
145
150
|
responseDeclarations,
|
|
@@ -468,12 +473,18 @@ function parseInteraction(
|
|
|
468
473
|
responseCardinality: responseDeclaration?.cardinality,
|
|
469
474
|
responseBaseType: responseDeclaration?.baseType,
|
|
470
475
|
prompt: prompt ? textContent(prompt) : undefined,
|
|
476
|
+
promptAttributes: prompt?.attributes,
|
|
477
|
+
promptSource: prompt?.source,
|
|
471
478
|
contextText: inlineInteractionContext(node, interactionType),
|
|
472
479
|
object: parseObjectAsset(objectNode),
|
|
473
480
|
positionObjectStage:
|
|
474
481
|
interactionType === "positionObject"
|
|
475
482
|
? parseObjectAsset(positionObjectStageObject(node))
|
|
476
483
|
: undefined,
|
|
484
|
+
portableCustom:
|
|
485
|
+
interactionType === "portableCustom"
|
|
486
|
+
? parsePortableCustomDefinition(node, diagnostics)
|
|
487
|
+
: undefined,
|
|
477
488
|
choices: parseChoices(node),
|
|
478
489
|
hottextSegments: interactionType === "hottext" ? parseHottextSegments(node) : undefined,
|
|
479
490
|
gapMatchSegments:
|
|
@@ -490,6 +501,172 @@ function parseInteraction(
|
|
|
490
501
|
};
|
|
491
502
|
}
|
|
492
503
|
|
|
504
|
+
function parsePortableCustomDefinition(
|
|
505
|
+
node: XmlNode,
|
|
506
|
+
diagnostics: QtiDiagnostic[],
|
|
507
|
+
): QtiPortableCustomDefinition {
|
|
508
|
+
const markup = firstChildElement(node, "qti-interaction-markup", diagnostics);
|
|
509
|
+
const modules = firstChildElement(node, "qti-interaction-modules", diagnostics);
|
|
510
|
+
return {
|
|
511
|
+
responseIdentifier: node.attributes["response-identifier"],
|
|
512
|
+
customInteractionTypeIdentifier: node.attributes["custom-interaction-type-identifier"],
|
|
513
|
+
module: node.attributes.module,
|
|
514
|
+
interactionModules: modules ? parsePortableCustomInteractionModules(modules) : undefined,
|
|
515
|
+
interactionMarkup: markup ? parsePortableCustomMarkupChildren(markup, diagnostics) : [],
|
|
516
|
+
interactionMarkupRaw: markup ? serializeXmlContent(markup) : undefined,
|
|
517
|
+
templateVariables: childElements(node, "qti-template-variable").map((variable) =>
|
|
518
|
+
parsePortableCustomVariableBinding(variable, "template"),
|
|
519
|
+
),
|
|
520
|
+
contextVariables: childElements(node, "qti-context-variable").map((variable) =>
|
|
521
|
+
parsePortableCustomVariableBinding(variable, "context"),
|
|
522
|
+
),
|
|
523
|
+
stylesheets: childElements(node, "qti-stylesheet").map(parseStylesheet),
|
|
524
|
+
catalogInfo: parseCatalogInfo(childElements(node, "qti-catalog-info")[0]),
|
|
525
|
+
dataAttributes: Object.fromEntries(
|
|
526
|
+
Object.entries(node.attributes).filter(([name]) => name.startsWith("data-")),
|
|
527
|
+
),
|
|
528
|
+
attributes: node.attributes,
|
|
529
|
+
source: node.source,
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function firstChildElement(
|
|
534
|
+
node: XmlNode,
|
|
535
|
+
name: string,
|
|
536
|
+
diagnostics: QtiDiagnostic[],
|
|
537
|
+
): XmlNode | undefined {
|
|
538
|
+
const matches = childElements(node, name);
|
|
539
|
+
if (matches.length > 1) {
|
|
540
|
+
diagnostics.push({
|
|
541
|
+
code: "interaction.portableCustom.child.duplicate",
|
|
542
|
+
severity: "error",
|
|
543
|
+
message: `${node.localName} allows at most one ${name} child.`,
|
|
544
|
+
path: matches[1]?.source?.path ?? node.source?.path,
|
|
545
|
+
source: matches[1]?.source ?? node.source,
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
return matches[0];
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function parsePortableCustomMarkupChildren(
|
|
552
|
+
node: XmlNode,
|
|
553
|
+
diagnostics: QtiDiagnostic[],
|
|
554
|
+
): QtiContentNode[] {
|
|
555
|
+
const content: QtiContentNode[] = [];
|
|
556
|
+
for (const entry of node.content) {
|
|
557
|
+
if (typeof entry === "string") {
|
|
558
|
+
if (entry.length > 0) content.push({ kind: "text", text: entry, source: node.source });
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
content.push(parsePortableCustomMarkupNode(entry, diagnostics));
|
|
562
|
+
}
|
|
563
|
+
return content;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function parsePortableCustomMarkupNode(
|
|
567
|
+
node: XmlNode,
|
|
568
|
+
diagnostics: QtiDiagnostic[],
|
|
569
|
+
): QtiContentNode {
|
|
570
|
+
if (isInteractionElement(node)) {
|
|
571
|
+
diagnostics.push({
|
|
572
|
+
code: "interaction.portableCustom.markupInteraction",
|
|
573
|
+
severity: "error",
|
|
574
|
+
message: `qti-interaction-markup must not contain nested QTI interaction ${node.localName}.`,
|
|
575
|
+
path: node.source?.path,
|
|
576
|
+
source: node.source,
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
if (node.localName === "qti-printed-variable") {
|
|
581
|
+
return {
|
|
582
|
+
kind: "printedVariable",
|
|
583
|
+
identifier: node.attributes.identifier ?? "",
|
|
584
|
+
format: node.attributes.format,
|
|
585
|
+
attributes: node.attributes,
|
|
586
|
+
source: node.source,
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
if (node.localName === "qti-feedback-block" || node.localName === "qti-feedback-inline") {
|
|
591
|
+
return {
|
|
592
|
+
kind: "feedback",
|
|
593
|
+
feedbackType: node.localName === "qti-feedback-block" ? "block" : "inline",
|
|
594
|
+
identifier: node.attributes.identifier ?? "",
|
|
595
|
+
outcomeIdentifier: node.attributes["outcome-identifier"] ?? "",
|
|
596
|
+
showHide: node.attributes["show-hide"] === "hide" ? "hide" : "show",
|
|
597
|
+
attributes: node.attributes,
|
|
598
|
+
children: parsePortableCustomMarkupChildren(node, diagnostics),
|
|
599
|
+
source: node.source,
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
return {
|
|
604
|
+
kind: "element",
|
|
605
|
+
qtiName: node.localName,
|
|
606
|
+
attributes: node.attributes,
|
|
607
|
+
children: parsePortableCustomMarkupChildren(node, diagnostics),
|
|
608
|
+
source: node.source,
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function parsePortableCustomInteractionModules(node: XmlNode): QtiPortableCustomInteractionModules {
|
|
613
|
+
return {
|
|
614
|
+
primaryConfiguration: node.attributes["primary-configuration"],
|
|
615
|
+
secondaryConfiguration:
|
|
616
|
+
node.attributes["secondary-configuration"] ?? node.attributes["fallback-configuration"],
|
|
617
|
+
modules: childElements(node, "qti-interaction-module").map(
|
|
618
|
+
parsePortableCustomInteractionModule,
|
|
619
|
+
),
|
|
620
|
+
attributes: node.attributes,
|
|
621
|
+
source: node.source,
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function parsePortableCustomInteractionModule(node: XmlNode): QtiPortableCustomInteractionModule {
|
|
626
|
+
return {
|
|
627
|
+
id: node.attributes.id,
|
|
628
|
+
primaryPath: node.attributes["primary-path"],
|
|
629
|
+
fallbackPath: node.attributes["fallback-path"],
|
|
630
|
+
attributes: node.attributes,
|
|
631
|
+
source: node.source,
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function parsePortableCustomVariableBinding(
|
|
636
|
+
node: XmlNode,
|
|
637
|
+
kind: QtiPortableCustomVariableBinding["kind"],
|
|
638
|
+
): QtiPortableCustomVariableBinding {
|
|
639
|
+
return {
|
|
640
|
+
kind,
|
|
641
|
+
identifier: node.attributes.identifier ?? node.attributes["template-identifier"],
|
|
642
|
+
variableIdentifier: node.attributes["variable-identifier"],
|
|
643
|
+
attributes: node.attributes,
|
|
644
|
+
source: node.source,
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function serializeXmlContent(node: XmlNode): string {
|
|
649
|
+
return node.content
|
|
650
|
+
.map((entry) => (typeof entry === "string" ? escapeXmlText(entry) : serializeXmlNode(entry)))
|
|
651
|
+
.join("");
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function serializeXmlNode(node: XmlNode): string {
|
|
655
|
+
const attributes = Object.entries(node.attributes)
|
|
656
|
+
.map(([name, value]) => ` ${name}="${escapeXmlAttribute(value)}"`)
|
|
657
|
+
.join("");
|
|
658
|
+
if (node.content.length === 0) return `<${node.name}${attributes}/>`;
|
|
659
|
+
return `<${node.name}${attributes}>${serializeXmlContent(node)}</${node.name}>`;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function escapeXmlText(value: string): string {
|
|
663
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function escapeXmlAttribute(value: string): string {
|
|
667
|
+
return escapeXmlText(value).replaceAll('"', """);
|
|
668
|
+
}
|
|
669
|
+
|
|
493
670
|
function positionObjectInteractionObject(node: XmlNode): XmlNode | undefined {
|
|
494
671
|
return childElements(node).find(
|
|
495
672
|
(child) => child.localName === "object" || child.localName === "img",
|
|
@@ -620,6 +797,7 @@ function parseObjectAsset(node: XmlNode | undefined): QtiObjectAsset | undefined
|
|
|
620
797
|
const data = node.attributes.data ?? node.attributes.src ?? pictureImage?.attributes.src;
|
|
621
798
|
const sources = parseMediaSources(node);
|
|
622
799
|
const tracks = parseMediaTracks(node);
|
|
800
|
+
const inferredSvgDimensions = inlineSvgDimensions(data);
|
|
623
801
|
return {
|
|
624
802
|
data,
|
|
625
803
|
type:
|
|
@@ -627,16 +805,101 @@ function parseObjectAsset(node: XmlNode | undefined): QtiObjectAsset | undefined
|
|
|
627
805
|
pictureImage?.attributes.type ??
|
|
628
806
|
assetTypeFromData(data) ??
|
|
629
807
|
firstSourceType(sources),
|
|
630
|
-
width: node.attributes.width ?? pictureImage?.attributes.width,
|
|
631
|
-
height:
|
|
808
|
+
width: node.attributes.width ?? pictureImage?.attributes.width ?? inferredSvgDimensions?.width,
|
|
809
|
+
height:
|
|
810
|
+
node.attributes.height ?? pictureImage?.attributes.height ?? inferredSvgDimensions?.height,
|
|
632
811
|
sources,
|
|
633
812
|
tracks,
|
|
634
|
-
text: textContent(node) || pictureImage?.attributes.alt || "",
|
|
813
|
+
text: textContent(node) || node.attributes.alt || pictureImage?.attributes.alt || "",
|
|
635
814
|
attributes: node.attributes,
|
|
636
815
|
source: node.source,
|
|
637
816
|
};
|
|
638
817
|
}
|
|
639
818
|
|
|
819
|
+
function inlineSvgDimensions(
|
|
820
|
+
data: string | undefined,
|
|
821
|
+
): { width: string; height: string } | undefined {
|
|
822
|
+
const svgText = decodeInlineSvgData(data);
|
|
823
|
+
if (!svgText) return undefined;
|
|
824
|
+
const tree = parseXmlTree(svgText);
|
|
825
|
+
const svg = tree.root;
|
|
826
|
+
if (!svg || svg.localName !== "svg") return undefined;
|
|
827
|
+
|
|
828
|
+
const width = svgLength(svg.attributes.width);
|
|
829
|
+
const height = svgLength(svg.attributes.height);
|
|
830
|
+
if (width !== undefined && height !== undefined) {
|
|
831
|
+
return { width: formatDimension(width), height: formatDimension(height) };
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
const viewBox = svgViewBoxDimensions(svg.attributes.viewBox);
|
|
835
|
+
if (!viewBox) return undefined;
|
|
836
|
+
const inferredWidth = width ?? viewBox.width;
|
|
837
|
+
const inferredHeight = height ?? viewBox.height;
|
|
838
|
+
if (inferredWidth <= 0 || inferredHeight <= 0) return undefined;
|
|
839
|
+
return { width: formatDimension(inferredWidth), height: formatDimension(inferredHeight) };
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
function decodeInlineSvgData(data: string | undefined): string | undefined {
|
|
843
|
+
if (!data) return undefined;
|
|
844
|
+
const comma = data.indexOf(",");
|
|
845
|
+
if (comma < 0) return undefined;
|
|
846
|
+
const metadata = data.slice(0, comma);
|
|
847
|
+
if (!/^data:image\/svg\+xml(?:[;,]|$)/i.test(metadata)) return undefined;
|
|
848
|
+
const payload = data.slice(comma + 1);
|
|
849
|
+
if (/;base64(?:[;,]|$)/i.test(metadata)) return decodeBase64Ascii(payload);
|
|
850
|
+
try {
|
|
851
|
+
return decodeURIComponent(payload);
|
|
852
|
+
} catch {
|
|
853
|
+
return payload;
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
function decodeBase64Ascii(value: string): string | undefined {
|
|
858
|
+
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
859
|
+
const cleaned = value.replace(/\s+/g, "").replace(/=+$/, "");
|
|
860
|
+
let bits = 0;
|
|
861
|
+
let buffer = 0;
|
|
862
|
+
let output = "";
|
|
863
|
+
for (const char of cleaned) {
|
|
864
|
+
const digit = alphabet.indexOf(char);
|
|
865
|
+
if (digit < 0) return undefined;
|
|
866
|
+
buffer = (buffer << 6) | digit;
|
|
867
|
+
bits += 6;
|
|
868
|
+
if (bits < 8) continue;
|
|
869
|
+
bits -= 8;
|
|
870
|
+
output += String.fromCharCode((buffer >> bits) & 0xff);
|
|
871
|
+
}
|
|
872
|
+
return output;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
function svgLength(value: string | undefined): number | undefined {
|
|
876
|
+
if (!value || value.trim().endsWith("%")) return undefined;
|
|
877
|
+
const match = value.trim().match(/^(\d+(?:\.\d+)?|\.\d+)(?:px)?$/i);
|
|
878
|
+
if (!match?.[1]) return undefined;
|
|
879
|
+
const parsed = Number(match[1]);
|
|
880
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
function svgViewBoxDimensions(
|
|
884
|
+
value: string | undefined,
|
|
885
|
+
): { width: number; height: number } | undefined {
|
|
886
|
+
const parts = value
|
|
887
|
+
?.trim()
|
|
888
|
+
.split(/[\s,]+/)
|
|
889
|
+
.map((part) => Number(part));
|
|
890
|
+
if (!parts || parts.length !== 4 || parts.some((part) => !Number.isFinite(part))) {
|
|
891
|
+
return undefined;
|
|
892
|
+
}
|
|
893
|
+
const width = parts[2];
|
|
894
|
+
const height = parts[3];
|
|
895
|
+
if (width === undefined || height === undefined || width <= 0 || height <= 0) return undefined;
|
|
896
|
+
return { width, height };
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
function formatDimension(value: number): string {
|
|
900
|
+
return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(4)));
|
|
901
|
+
}
|
|
902
|
+
|
|
640
903
|
function parseMediaSources(node: XmlNode): QtiMediaSource[] {
|
|
641
904
|
return childElements(node, "source").map((source) => {
|
|
642
905
|
const src = source.attributes.src ?? firstSrcsetCandidate(source.attributes.srcset);
|