@longsightgroup/qti3-core 0.9.3 → 0.9.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/README.md +88 -8
- package/dist/adaptive-turn-materializer.d.ts +2 -0
- package/dist/adaptive-turn-materializer.d.ts.map +1 -1
- package/dist/adaptive-turn-materializer.js +25 -19
- package/dist/adaptive-turn-materializer.js.map +1 -1
- package/dist/adaptive-turn.d.ts +8 -1
- package/dist/adaptive-turn.d.ts.map +1 -1
- package/dist/adaptive-turn.js +15 -5
- package/dist/adaptive-turn.js.map +1 -1
- package/dist/delivery-preparation.d.ts +20 -0
- package/dist/delivery-preparation.d.ts.map +1 -0
- package/dist/delivery-preparation.js +71 -0
- package/dist/delivery-preparation.js.map +1 -0
- package/dist/delivery-redaction.d.ts +5 -0
- package/dist/delivery-redaction.d.ts.map +1 -1
- package/dist/delivery-redaction.js +26 -5
- package/dist/delivery-redaction.js.map +1 -1
- package/dist/delivery-security.js +2 -2
- package/dist/delivery-security.js.map +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/item-scoring-expectation.d.ts +4 -0
- package/dist/item-scoring-expectation.d.ts.map +1 -0
- package/dist/item-scoring-expectation.js +6 -0
- package/dist/item-scoring-expectation.js.map +1 -0
- package/dist/item-submission-materialization.d.ts +25 -0
- package/dist/item-submission-materialization.d.ts.map +1 -0
- package/dist/item-submission-materialization.js +68 -0
- package/dist/item-submission-materialization.js.map +1 -0
- package/dist/qti-package-assets.d.ts +8 -0
- package/dist/qti-package-assets.d.ts.map +1 -0
- package/dist/qti-package-assets.js +173 -0
- package/dist/qti-package-assets.js.map +1 -0
- package/dist/qti-package-items.d.ts +14 -0
- package/dist/qti-package-items.d.ts.map +1 -0
- package/dist/qti-package-items.js +138 -0
- package/dist/qti-package-items.js.map +1 -0
- package/dist/qti-package-manifest.d.ts +13 -0
- package/dist/qti-package-manifest.d.ts.map +1 -0
- package/dist/qti-package-manifest.js +109 -0
- package/dist/qti-package-manifest.js.map +1 -0
- package/dist/qti-package-metadata.d.ts +8 -0
- package/dist/qti-package-metadata.d.ts.map +1 -0
- package/dist/qti-package-metadata.js +115 -0
- package/dist/qti-package-metadata.js.map +1 -0
- package/dist/qti-package-paths.d.ts +6 -0
- package/dist/qti-package-paths.d.ts.map +1 -0
- package/dist/qti-package-paths.js +32 -0
- package/dist/qti-package-paths.js.map +1 -0
- package/dist/qti-package-types.d.ts +98 -0
- package/dist/qti-package-types.d.ts.map +1 -0
- package/dist/qti-package-types.js +2 -0
- package/dist/qti-package-types.js.map +1 -0
- package/dist/qti-package-xml.d.ts +21 -0
- package/dist/qti-package-xml.d.ts.map +1 -0
- package/dist/qti-package-xml.js +68 -0
- package/dist/qti-package-xml.js.map +1 -0
- package/dist/qti-package-zip.d.ts +24 -0
- package/dist/qti-package-zip.d.ts.map +1 -0
- package/dist/qti-package-zip.js +185 -0
- package/dist/qti-package-zip.js.map +1 -0
- package/dist/qti-package.d.ts +7 -0
- package/dist/qti-package.d.ts.map +1 -0
- package/dist/qti-package.js +76 -0
- package/dist/qti-package.js.map +1 -0
- package/dist/response-input.d.ts +9 -0
- package/dist/response-input.d.ts.map +1 -0
- package/dist/response-input.js +9 -0
- package/dist/response-input.js.map +1 -0
- package/dist/response-validation-policy.d.ts +31 -0
- package/dist/response-validation-policy.d.ts.map +1 -0
- package/dist/response-validation-policy.js +158 -0
- package/dist/response-validation-policy.js.map +1 -0
- package/dist/response-validation.d.ts +23 -0
- package/dist/response-validation.d.ts.map +1 -0
- package/dist/response-validation.js +157 -0
- package/dist/response-validation.js.map +1 -0
- package/dist/scoring-disposition-policy.d.ts +8 -0
- package/dist/scoring-disposition-policy.d.ts.map +1 -0
- package/dist/scoring-disposition-policy.js +24 -0
- package/dist/scoring-disposition-policy.js.map +1 -0
- package/dist/template-presentation-materializer.d.ts +14 -0
- package/dist/template-presentation-materializer.d.ts.map +1 -0
- package/dist/template-presentation-materializer.js +143 -0
- package/dist/template-presentation-materializer.js.map +1 -0
- package/dist/trusted-item-session.d.ts +25 -8
- package/dist/trusted-item-session.d.ts.map +1 -1
- package/dist/trusted-item-session.js +51 -46
- package/dist/trusted-item-session.js.map +1 -1
- package/dist/trusted-session-materialization.d.ts +33 -0
- package/dist/trusted-session-materialization.d.ts.map +1 -0
- package/dist/trusted-session-materialization.js +26 -0
- package/dist/trusted-session-materialization.js.map +1 -0
- package/dist/validation-primitives.d.ts +1 -0
- package/dist/validation-primitives.d.ts.map +1 -1
- package/dist/validation-primitives.js +5 -0
- package/dist/validation-primitives.js.map +1 -1
- package/dist/variable-snapshot.d.ts +9 -0
- package/dist/variable-snapshot.d.ts.map +1 -0
- package/dist/variable-snapshot.js +9 -0
- package/dist/variable-snapshot.js.map +1 -0
- package/package.json +1 -1
- package/src/adaptive-turn-materializer.ts +29 -16
- package/src/adaptive-turn.ts +29 -6
- package/src/delivery-preparation.ts +121 -0
- package/src/delivery-redaction.ts +36 -5
- package/src/delivery-security.ts +2 -2
- package/src/index.ts +65 -0
- package/src/item-scoring-expectation.ts +9 -0
- package/src/item-submission-materialization.ts +120 -0
- package/src/qti-package-assets.ts +256 -0
- package/src/qti-package-items.ts +240 -0
- package/src/qti-package-manifest.ts +210 -0
- package/src/qti-package-metadata.ts +143 -0
- package/src/qti-package-paths.ts +56 -0
- package/src/qti-package-types.ts +117 -0
- package/src/qti-package-xml.ts +97 -0
- package/src/qti-package-zip.ts +325 -0
- package/src/qti-package.ts +176 -0
- package/src/response-input.ts +19 -0
- package/src/response-validation-policy.ts +218 -0
- package/src/response-validation.ts +282 -0
- package/src/scoring-disposition-policy.ts +37 -0
- package/src/template-presentation-materializer.ts +219 -0
- package/src/trusted-item-session.ts +80 -74
- package/src/trusted-session-materialization.ts +67 -0
- package/src/validation-primitives.ts +5 -0
- package/src/variable-snapshot.ts +20 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { QtiDiagnostic, QtiDocument } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export type {
|
|
4
|
+
QtiPackageInflateContext,
|
|
5
|
+
QtiPackageInflateRaw,
|
|
6
|
+
QtiPackageParseOptions,
|
|
7
|
+
} from "./qti-package-zip.js";
|
|
8
|
+
|
|
9
|
+
/** Neutral package shape detected from IMS manifest resources. */
|
|
10
|
+
export type QtiPackageShape = "manifest-item-resources" | "assessment-test-resource" | "unknown";
|
|
11
|
+
|
|
12
|
+
/** Origin of an assessment item inside a parsed QTI package. */
|
|
13
|
+
export type QtiPackageItemSource = "manifest" | "assessment-test";
|
|
14
|
+
|
|
15
|
+
/** Source of a package-local asset reference. */
|
|
16
|
+
export type QtiPackageAssetSource =
|
|
17
|
+
| "manifest-resource"
|
|
18
|
+
| "item-content"
|
|
19
|
+
| "assessment-test-content";
|
|
20
|
+
|
|
21
|
+
/** Parsed package manifest file entry. */
|
|
22
|
+
export interface QtiManifestFile {
|
|
23
|
+
readonly href: string;
|
|
24
|
+
readonly attributes: Record<string, string>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Parsed package manifest resource entry. */
|
|
28
|
+
export interface QtiManifestResource {
|
|
29
|
+
readonly identifier: string;
|
|
30
|
+
readonly type: string;
|
|
31
|
+
readonly href?: string | undefined;
|
|
32
|
+
readonly files: readonly QtiManifestFile[];
|
|
33
|
+
readonly dependencies: readonly string[];
|
|
34
|
+
readonly attributes: Record<string, string>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Parsed timing metadata from item or assessment-test XML. */
|
|
38
|
+
export interface QtiTimingMetadata {
|
|
39
|
+
readonly sourcePath: string;
|
|
40
|
+
readonly timeDependent?: boolean | undefined;
|
|
41
|
+
readonly minTime?: string | undefined;
|
|
42
|
+
readonly maxTime?: string | undefined;
|
|
43
|
+
readonly allowLateSubmission?: boolean | undefined;
|
|
44
|
+
readonly attributes: Record<string, string>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Neutral standard-alignment metadata discovered in package XML. */
|
|
48
|
+
export interface QtiStandardAlignment {
|
|
49
|
+
readonly sourcePath: string;
|
|
50
|
+
readonly qtiName: string;
|
|
51
|
+
readonly identifier?: string | undefined;
|
|
52
|
+
readonly framework?: string | undefined;
|
|
53
|
+
readonly targetName?: string | undefined;
|
|
54
|
+
readonly targetUrl?: string | undefined;
|
|
55
|
+
readonly text?: string | undefined;
|
|
56
|
+
readonly attributes: Record<string, string>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Assessment item reference from an assessment-test package. */
|
|
60
|
+
export interface QtiAssessmentTestItemRef {
|
|
61
|
+
readonly identifier?: string | undefined;
|
|
62
|
+
readonly href: string;
|
|
63
|
+
readonly attributes: Record<string, string>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Parsed assessment-test resource model for package-level importers. */
|
|
67
|
+
export interface QtiAssessmentTestPackageModel {
|
|
68
|
+
readonly href: string;
|
|
69
|
+
readonly identifier: string;
|
|
70
|
+
readonly title?: string | undefined;
|
|
71
|
+
readonly manifestResourceIdentifier?: string | undefined;
|
|
72
|
+
readonly itemRefs: readonly QtiAssessmentTestItemRef[];
|
|
73
|
+
readonly timing?: QtiTimingMetadata | undefined;
|
|
74
|
+
readonly standards: readonly QtiStandardAlignment[];
|
|
75
|
+
readonly assetHrefs: readonly string[];
|
|
76
|
+
readonly diagnostics: readonly QtiDiagnostic[];
|
|
77
|
+
readonly attributes: Record<string, string>;
|
|
78
|
+
readonly xml: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Parsed assessment item model inside a QTI package. */
|
|
82
|
+
export interface QtiPackageItem {
|
|
83
|
+
readonly href: string;
|
|
84
|
+
readonly source: QtiPackageItemSource;
|
|
85
|
+
readonly manifestResourceIdentifier?: string | undefined;
|
|
86
|
+
readonly assessmentItemRefIdentifier?: string | undefined;
|
|
87
|
+
readonly identifier?: string | undefined;
|
|
88
|
+
readonly title?: string | undefined;
|
|
89
|
+
readonly document?: QtiDocument | undefined;
|
|
90
|
+
readonly timing?: QtiTimingMetadata | undefined;
|
|
91
|
+
readonly standards: readonly QtiStandardAlignment[];
|
|
92
|
+
readonly assetHrefs: readonly string[];
|
|
93
|
+
readonly diagnostics: readonly QtiDiagnostic[];
|
|
94
|
+
readonly xml: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Package-local asset discovered from manifest files or XML content references. */
|
|
98
|
+
export interface QtiPackageAsset {
|
|
99
|
+
readonly href: string;
|
|
100
|
+
readonly mediaType?: string | undefined;
|
|
101
|
+
readonly source: QtiPackageAssetSource;
|
|
102
|
+
readonly referencedBy: readonly string[];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Parsed QTI package manifest/resource graph and item/test projection. */
|
|
106
|
+
export interface QtiPackageParseResult {
|
|
107
|
+
readonly ok: boolean;
|
|
108
|
+
readonly title: string;
|
|
109
|
+
readonly packageShape: QtiPackageShape;
|
|
110
|
+
readonly items: readonly QtiPackageItem[];
|
|
111
|
+
readonly assets: readonly QtiPackageAsset[];
|
|
112
|
+
readonly manifestResources: readonly QtiManifestResource[];
|
|
113
|
+
readonly assessmentTest?: QtiAssessmentTestPackageModel | undefined;
|
|
114
|
+
readonly timing?: QtiTimingMetadata | undefined;
|
|
115
|
+
readonly standards: readonly QtiStandardAlignment[];
|
|
116
|
+
readonly diagnostics: readonly QtiDiagnostic[];
|
|
117
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { parseQtiPackageXmlTree, type QtiPackageXmlNode } from "./package-xml.js";
|
|
2
|
+
import { normalizePackagePath, pushPackageDiagnostic } from "./qti-package-paths.js";
|
|
3
|
+
import { decodeUtf8, type QtiPackageEntry } from "./qti-package-zip.js";
|
|
4
|
+
import type { QtiDiagnostic } from "./types.js";
|
|
5
|
+
|
|
6
|
+
/** Parsed package XML file with lightweight tree and source text. */
|
|
7
|
+
export interface PackageXmlFile {
|
|
8
|
+
readonly path: string;
|
|
9
|
+
readonly xml: string;
|
|
10
|
+
readonly root?: QtiPackageXmlNode | undefined;
|
|
11
|
+
readonly errors: readonly string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function parseXmlFiles(
|
|
15
|
+
entriesByPath: ReadonlyMap<string, QtiPackageEntry>,
|
|
16
|
+
): Map<string, PackageXmlFile> {
|
|
17
|
+
const xmlFiles = new Map<string, PackageXmlFile>();
|
|
18
|
+
for (const entry of entriesByPath.values()) {
|
|
19
|
+
if (!entry.path.toLowerCase().endsWith(".xml")) continue;
|
|
20
|
+
xmlFiles.set(entry.path, parsePackageXml(entry));
|
|
21
|
+
}
|
|
22
|
+
return xmlFiles;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function parsePackageXml(entry: QtiPackageEntry): PackageXmlFile {
|
|
26
|
+
const xml = decodeUtf8(entry.bytes);
|
|
27
|
+
const parsed = parseQtiPackageXmlTree(xml);
|
|
28
|
+
return {
|
|
29
|
+
path: entry.path,
|
|
30
|
+
xml,
|
|
31
|
+
root: parsed.root,
|
|
32
|
+
errors: parsed.errors,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function pushXmlDiagnostics(xmlFile: PackageXmlFile, diagnostics: QtiDiagnostic[]): void {
|
|
37
|
+
for (const message of xmlFile.errors) {
|
|
38
|
+
pushPackageDiagnostic(diagnostics, "xml.parse", "error", message, xmlFile.path);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function childPackageElements(
|
|
43
|
+
node: QtiPackageXmlNode,
|
|
44
|
+
localName?: string,
|
|
45
|
+
): QtiPackageXmlNode[] {
|
|
46
|
+
return node.children.filter((child) => !localName || child.localName === localName);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function packageDescendants(
|
|
50
|
+
node: QtiPackageXmlNode | undefined,
|
|
51
|
+
localName?: string,
|
|
52
|
+
): QtiPackageXmlNode[] {
|
|
53
|
+
if (!node) return [];
|
|
54
|
+
const found: QtiPackageXmlNode[] = [];
|
|
55
|
+
for (const child of node.children) {
|
|
56
|
+
if (!localName || child.localName === localName) found.push(child);
|
|
57
|
+
found.push(...packageDescendants(child, localName));
|
|
58
|
+
}
|
|
59
|
+
return found;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function resolvePackageHref(
|
|
63
|
+
from: string,
|
|
64
|
+
href: string,
|
|
65
|
+
diagnostics: QtiDiagnostic[],
|
|
66
|
+
): string | undefined {
|
|
67
|
+
const strippedHref = stripHrefSuffix(href);
|
|
68
|
+
const base = from.includes("/") ? from.slice(0, from.lastIndexOf("/") + 1) : "";
|
|
69
|
+
return normalizePackagePath(`${base}${strippedHref}`, "package reference", diagnostics);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function stripHrefSuffix(href: string): string {
|
|
73
|
+
return href.split(/[?#]/, 1)[0] ?? "";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function isPackageRelativeHref(href: string | undefined): href is string {
|
|
77
|
+
const trimmed = href?.trim() ?? "";
|
|
78
|
+
if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith("//")) return false;
|
|
79
|
+
return !/^[a-z][a-z0-9+.-]*:/i.test(trimmed);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function joinPackagePath(packagePath: string, localPath: string): string {
|
|
83
|
+
return localPath.startsWith("/") ? `${packagePath}${localPath}` : `${packagePath}/${localPath}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function scopeDiagnosticToPackagePath(
|
|
87
|
+
packagePath: string,
|
|
88
|
+
diagnostic: QtiDiagnostic,
|
|
89
|
+
): QtiDiagnostic {
|
|
90
|
+
const localPath = diagnostic.path ?? diagnostic.source?.path;
|
|
91
|
+
const path = localPath ? joinPackagePath(packagePath, localPath) : packagePath;
|
|
92
|
+
return {
|
|
93
|
+
...diagnostic,
|
|
94
|
+
path,
|
|
95
|
+
source: diagnostic.source ? { ...diagnostic.source, path } : diagnostic.source,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import { normalizePackagePath, pushPackageDiagnostic } from "./qti-package-paths.js";
|
|
2
|
+
import type { QtiDiagnostic } from "./types.js";
|
|
3
|
+
|
|
4
|
+
/** Context passed to a caller-owned raw DEFLATE inflater. */
|
|
5
|
+
export interface QtiPackageInflateContext {
|
|
6
|
+
readonly path: string;
|
|
7
|
+
readonly compressedSize: number;
|
|
8
|
+
readonly uncompressedSize: number;
|
|
9
|
+
readonly compressionMethod: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Optional raw DEFLATE inflater for ZIP entries compressed with method 8. */
|
|
13
|
+
export type QtiPackageInflateRaw = (
|
|
14
|
+
bytes: Uint8Array,
|
|
15
|
+
context: QtiPackageInflateContext,
|
|
16
|
+
) => Uint8Array;
|
|
17
|
+
|
|
18
|
+
/** Options for parsing a QTI ZIP package. */
|
|
19
|
+
export interface QtiPackageParseOptions {
|
|
20
|
+
readonly inflateRaw?: QtiPackageInflateRaw | undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Decoded ZIP entry from a QTI package. */
|
|
24
|
+
export interface QtiPackageEntry {
|
|
25
|
+
readonly path: string;
|
|
26
|
+
readonly bytes: Uint8Array;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Read stored ZIP entries and optionally inflate raw DEFLATE entries through caller code. */
|
|
30
|
+
export function readQtiPackageZipEntries(
|
|
31
|
+
bytes: Uint8Array,
|
|
32
|
+
options: QtiPackageParseOptions,
|
|
33
|
+
diagnostics: QtiDiagnostic[],
|
|
34
|
+
): QtiPackageEntry[] {
|
|
35
|
+
try {
|
|
36
|
+
return readZipEntriesUnsafe(bytes, options, diagnostics);
|
|
37
|
+
} catch {
|
|
38
|
+
pushPackageDiagnostic(
|
|
39
|
+
diagnostics,
|
|
40
|
+
"package.zip.invalid",
|
|
41
|
+
"error",
|
|
42
|
+
"QTI package ZIP central directory is malformed.",
|
|
43
|
+
);
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Intentionally manual: core's TS lib is ES2023-only (no DOM/Node globals).
|
|
49
|
+
// TextDecoder is available on all supported runtimes, but using it would require
|
|
50
|
+
// widening lib, ambient types, or globalThis casts in this zero-runtime-dependency package.
|
|
51
|
+
/** Decode UTF-8 package entry names and XML payloads. */
|
|
52
|
+
export function decodeUtf8(bytes: Uint8Array): string {
|
|
53
|
+
const input =
|
|
54
|
+
bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf ? bytes.subarray(3) : bytes;
|
|
55
|
+
let text = "";
|
|
56
|
+
|
|
57
|
+
for (let index = 0; index < input.length; ) {
|
|
58
|
+
const first = input[index];
|
|
59
|
+
if (first === undefined) break;
|
|
60
|
+
if (first < 0x80) {
|
|
61
|
+
text += String.fromCharCode(first);
|
|
62
|
+
index += 1;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const decoded = decodeUtf8CodePoint(input, index);
|
|
67
|
+
text += String.fromCodePoint(decoded.codePoint);
|
|
68
|
+
index += decoded.length;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return text;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function readZipEntriesUnsafe(
|
|
75
|
+
bytes: Uint8Array,
|
|
76
|
+
options: QtiPackageParseOptions,
|
|
77
|
+
diagnostics: QtiDiagnostic[],
|
|
78
|
+
): QtiPackageEntry[] {
|
|
79
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
80
|
+
const eocdOffset = findEndOfCentralDirectory(view);
|
|
81
|
+
if (eocdOffset < 0) {
|
|
82
|
+
pushPackageDiagnostic(
|
|
83
|
+
diagnostics,
|
|
84
|
+
"package.zip.centralDirectory.missing",
|
|
85
|
+
"error",
|
|
86
|
+
"No ZIP central directory was found.",
|
|
87
|
+
);
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const entryCount = view.getUint16(eocdOffset + 10, true);
|
|
92
|
+
let offset = view.getUint32(eocdOffset + 16, true);
|
|
93
|
+
const entries: QtiPackageEntry[] = [];
|
|
94
|
+
|
|
95
|
+
for (let index = 0; index < entryCount; index += 1) {
|
|
96
|
+
if (offset + 46 > view.byteLength) {
|
|
97
|
+
pushPackageDiagnostic(
|
|
98
|
+
diagnostics,
|
|
99
|
+
"package.zip.centralDirectory.truncated",
|
|
100
|
+
"error",
|
|
101
|
+
"ZIP central directory ended before all entries could be read.",
|
|
102
|
+
);
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (view.getUint32(offset, true) !== 0x02014b50) {
|
|
107
|
+
pushPackageDiagnostic(
|
|
108
|
+
diagnostics,
|
|
109
|
+
"package.zip.centralDirectory.entry",
|
|
110
|
+
"error",
|
|
111
|
+
"ZIP central directory entry header is invalid.",
|
|
112
|
+
);
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const compressionMethod = view.getUint16(offset + 10, true);
|
|
117
|
+
const compressedSize = view.getUint32(offset + 20, true);
|
|
118
|
+
const uncompressedSize = view.getUint32(offset + 24, true);
|
|
119
|
+
const nameLength = view.getUint16(offset + 28, true);
|
|
120
|
+
const extraLength = view.getUint16(offset + 30, true);
|
|
121
|
+
const commentLength = view.getUint16(offset + 32, true);
|
|
122
|
+
const localHeaderOffset = view.getUint32(offset + 42, true);
|
|
123
|
+
const nameStart = offset + 46;
|
|
124
|
+
const nameEnd = nameStart + nameLength;
|
|
125
|
+
if (nameEnd > view.byteLength) {
|
|
126
|
+
pushPackageDiagnostic(
|
|
127
|
+
diagnostics,
|
|
128
|
+
"package.zip.centralDirectory.name",
|
|
129
|
+
"error",
|
|
130
|
+
"ZIP central directory entry name is truncated.",
|
|
131
|
+
);
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const rawName = bytes.slice(nameStart, nameEnd);
|
|
136
|
+
const decodedName = decodeUtf8(rawName).replaceAll("\\", "/");
|
|
137
|
+
offset += 46 + nameLength + extraLength + commentLength;
|
|
138
|
+
if (decodedName.endsWith("/")) continue;
|
|
139
|
+
|
|
140
|
+
const path = normalizePackagePath(decodedName, "ZIP entry", diagnostics);
|
|
141
|
+
if (!path) continue;
|
|
142
|
+
|
|
143
|
+
const content = zipEntryBytes(
|
|
144
|
+
bytes,
|
|
145
|
+
view,
|
|
146
|
+
{
|
|
147
|
+
path,
|
|
148
|
+
compressedSize,
|
|
149
|
+
uncompressedSize,
|
|
150
|
+
compressionMethod,
|
|
151
|
+
localHeaderOffset,
|
|
152
|
+
},
|
|
153
|
+
options,
|
|
154
|
+
diagnostics,
|
|
155
|
+
);
|
|
156
|
+
if (content) entries.push({ path, bytes: content });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return entries;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function zipEntryBytes(
|
|
163
|
+
bytes: Uint8Array,
|
|
164
|
+
view: DataView,
|
|
165
|
+
entry: {
|
|
166
|
+
readonly path: string;
|
|
167
|
+
readonly compressedSize: number;
|
|
168
|
+
readonly uncompressedSize: number;
|
|
169
|
+
readonly compressionMethod: number;
|
|
170
|
+
readonly localHeaderOffset: number;
|
|
171
|
+
},
|
|
172
|
+
options: QtiPackageParseOptions,
|
|
173
|
+
diagnostics: QtiDiagnostic[],
|
|
174
|
+
): Uint8Array | undefined {
|
|
175
|
+
if (entry.localHeaderOffset + 30 > view.byteLength) {
|
|
176
|
+
pushPackageDiagnostic(
|
|
177
|
+
diagnostics,
|
|
178
|
+
"package.zip.localHeader.truncated",
|
|
179
|
+
"error",
|
|
180
|
+
`ZIP local header for ${entry.path} is truncated.`,
|
|
181
|
+
entry.path,
|
|
182
|
+
);
|
|
183
|
+
return undefined;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (view.getUint32(entry.localHeaderOffset, true) !== 0x04034b50) {
|
|
187
|
+
pushPackageDiagnostic(
|
|
188
|
+
diagnostics,
|
|
189
|
+
"package.zip.localHeader",
|
|
190
|
+
"error",
|
|
191
|
+
`ZIP local header for ${entry.path} is invalid.`,
|
|
192
|
+
entry.path,
|
|
193
|
+
);
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const nameLength = view.getUint16(entry.localHeaderOffset + 26, true);
|
|
198
|
+
const extraLength = view.getUint16(entry.localHeaderOffset + 28, true);
|
|
199
|
+
const dataOffset = entry.localHeaderOffset + 30 + nameLength + extraLength;
|
|
200
|
+
const dataEnd = dataOffset + entry.compressedSize;
|
|
201
|
+
if (dataEnd > view.byteLength) {
|
|
202
|
+
pushPackageDiagnostic(
|
|
203
|
+
diagnostics,
|
|
204
|
+
"package.zip.entry.truncated",
|
|
205
|
+
"error",
|
|
206
|
+
`ZIP entry ${entry.path} content is truncated.`,
|
|
207
|
+
entry.path,
|
|
208
|
+
);
|
|
209
|
+
return undefined;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const compressed = bytes.slice(dataOffset, dataEnd);
|
|
213
|
+
if (entry.compressionMethod === 0) return compressed;
|
|
214
|
+
|
|
215
|
+
if (entry.compressionMethod === 8) {
|
|
216
|
+
const inflateRaw = options.inflateRaw;
|
|
217
|
+
if (!inflateRaw) {
|
|
218
|
+
pushPackageDiagnostic(
|
|
219
|
+
diagnostics,
|
|
220
|
+
"package.zip.entry.compression.unsupported",
|
|
221
|
+
"error",
|
|
222
|
+
`ZIP entry ${entry.path} uses DEFLATE compression, but no inflateRaw option was provided.`,
|
|
223
|
+
entry.path,
|
|
224
|
+
);
|
|
225
|
+
return undefined;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
try {
|
|
229
|
+
return inflateRaw(compressed, {
|
|
230
|
+
path: entry.path,
|
|
231
|
+
compressedSize: entry.compressedSize,
|
|
232
|
+
uncompressedSize: entry.uncompressedSize,
|
|
233
|
+
compressionMethod: entry.compressionMethod,
|
|
234
|
+
});
|
|
235
|
+
} catch {
|
|
236
|
+
pushPackageDiagnostic(
|
|
237
|
+
diagnostics,
|
|
238
|
+
"package.zip.entry.inflate",
|
|
239
|
+
"error",
|
|
240
|
+
`ZIP entry ${entry.path} could not be inflated.`,
|
|
241
|
+
entry.path,
|
|
242
|
+
);
|
|
243
|
+
return undefined;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
pushPackageDiagnostic(
|
|
248
|
+
diagnostics,
|
|
249
|
+
"package.zip.entry.compression.unsupported",
|
|
250
|
+
"error",
|
|
251
|
+
`ZIP entry ${entry.path} uses unsupported compression method ${entry.compressionMethod}.`,
|
|
252
|
+
entry.path,
|
|
253
|
+
);
|
|
254
|
+
return undefined;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function findEndOfCentralDirectory(view: DataView): number {
|
|
258
|
+
const minimumOffset = Math.max(0, view.byteLength - 65557);
|
|
259
|
+
for (let offset = view.byteLength - 22; offset >= minimumOffset; offset -= 1) {
|
|
260
|
+
if (view.getUint32(offset, true) === 0x06054b50) return offset;
|
|
261
|
+
}
|
|
262
|
+
return -1;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function decodeUtf8CodePoint(
|
|
266
|
+
bytes: Uint8Array,
|
|
267
|
+
index: number,
|
|
268
|
+
): { readonly codePoint: number; readonly length: number } {
|
|
269
|
+
const first = bytes[index];
|
|
270
|
+
if (first === undefined) return { codePoint: 0xfffd, length: 1 };
|
|
271
|
+
|
|
272
|
+
if (first >= 0xc2 && first <= 0xdf) {
|
|
273
|
+
const second = bytes[index + 1];
|
|
274
|
+
if (isUtf8Continuation(second)) {
|
|
275
|
+
return {
|
|
276
|
+
codePoint: ((first & 0x1f) << 6) | (second & 0x3f),
|
|
277
|
+
length: 2,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (first >= 0xe0 && first <= 0xef) {
|
|
283
|
+
const second = bytes[index + 1];
|
|
284
|
+
const third = bytes[index + 2];
|
|
285
|
+
if (
|
|
286
|
+
isUtf8Continuation(second) &&
|
|
287
|
+
isUtf8Continuation(third) &&
|
|
288
|
+
!(first === 0xe0 && second < 0xa0) &&
|
|
289
|
+
!(first === 0xed && second >= 0xa0)
|
|
290
|
+
) {
|
|
291
|
+
return {
|
|
292
|
+
codePoint: ((first & 0x0f) << 12) | ((second & 0x3f) << 6) | (third & 0x3f),
|
|
293
|
+
length: 3,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (first >= 0xf0 && first <= 0xf4) {
|
|
299
|
+
const second = bytes[index + 1];
|
|
300
|
+
const third = bytes[index + 2];
|
|
301
|
+
const fourth = bytes[index + 3];
|
|
302
|
+
if (
|
|
303
|
+
isUtf8Continuation(second) &&
|
|
304
|
+
isUtf8Continuation(third) &&
|
|
305
|
+
isUtf8Continuation(fourth) &&
|
|
306
|
+
!(first === 0xf0 && second < 0x90) &&
|
|
307
|
+
!(first === 0xf4 && second >= 0x90)
|
|
308
|
+
) {
|
|
309
|
+
return {
|
|
310
|
+
codePoint:
|
|
311
|
+
((first & 0x07) << 18) |
|
|
312
|
+
((second & 0x3f) << 12) |
|
|
313
|
+
((third & 0x3f) << 6) |
|
|
314
|
+
(fourth & 0x3f),
|
|
315
|
+
length: 4,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return { codePoint: 0xfffd, length: 1 };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function isUtf8Continuation(value: number | undefined): value is number {
|
|
324
|
+
return value !== undefined && value >= 0x80 && value <= 0xbf;
|
|
325
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { pushPackageDiagnostic } from "./qti-package-paths.js";
|
|
2
|
+
import { collectPackageAssets } from "./qti-package-assets.js";
|
|
3
|
+
import {
|
|
4
|
+
detectPackageShape,
|
|
5
|
+
diagnoseManifestDependencyReferences,
|
|
6
|
+
diagnosePrimaryResourceHrefs,
|
|
7
|
+
indexManifestResources,
|
|
8
|
+
isQtiAssessmentTestResource,
|
|
9
|
+
isQtiItemResource,
|
|
10
|
+
parseManifestResources,
|
|
11
|
+
QTI_PACKAGE_MANIFEST_PATH,
|
|
12
|
+
} from "./qti-package-manifest.js";
|
|
13
|
+
import {
|
|
14
|
+
itemReferencesForPackageShape,
|
|
15
|
+
parseAssessmentTestPackageModel,
|
|
16
|
+
parsePackageItems,
|
|
17
|
+
} from "./qti-package-items.js";
|
|
18
|
+
import {
|
|
19
|
+
packageTitle,
|
|
20
|
+
parseStandardAlignments,
|
|
21
|
+
primaryTiming,
|
|
22
|
+
uniqueStandards,
|
|
23
|
+
} from "./qti-package-metadata.js";
|
|
24
|
+
import type { QtiPackageParseResult } from "./qti-package-types.js";
|
|
25
|
+
import { parseXmlFiles, pushXmlDiagnostics } from "./qti-package-xml.js";
|
|
26
|
+
import {
|
|
27
|
+
readQtiPackageZipEntries,
|
|
28
|
+
type QtiPackageEntry,
|
|
29
|
+
type QtiPackageParseOptions,
|
|
30
|
+
} from "./qti-package-zip.js";
|
|
31
|
+
import type { QtiDiagnostic } from "./types.js";
|
|
32
|
+
|
|
33
|
+
export type {
|
|
34
|
+
QtiAssessmentTestItemRef,
|
|
35
|
+
QtiAssessmentTestPackageModel,
|
|
36
|
+
QtiManifestFile,
|
|
37
|
+
QtiManifestResource,
|
|
38
|
+
QtiPackageAsset,
|
|
39
|
+
QtiPackageAssetSource,
|
|
40
|
+
QtiPackageItem,
|
|
41
|
+
QtiPackageItemSource,
|
|
42
|
+
QtiPackageParseResult,
|
|
43
|
+
QtiPackageShape,
|
|
44
|
+
QtiStandardAlignment,
|
|
45
|
+
QtiTimingMetadata,
|
|
46
|
+
} from "./qti-package-types.js";
|
|
47
|
+
|
|
48
|
+
export type {
|
|
49
|
+
QtiPackageInflateContext,
|
|
50
|
+
QtiPackageInflateRaw,
|
|
51
|
+
QtiPackageParseOptions,
|
|
52
|
+
} from "./qti-package-zip.js";
|
|
53
|
+
|
|
54
|
+
/** Parse a QTI ZIP package into a neutral manifest, item, asset, and diagnostic model. */
|
|
55
|
+
export function parseQtiPackage(
|
|
56
|
+
bytes: Uint8Array,
|
|
57
|
+
options: QtiPackageParseOptions = {},
|
|
58
|
+
): QtiPackageParseResult {
|
|
59
|
+
const diagnostics: QtiDiagnostic[] = [];
|
|
60
|
+
const entries = readQtiPackageZipEntries(bytes, options, diagnostics);
|
|
61
|
+
return parseQtiPackageEntries(entries, diagnostics);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function parseQtiPackageEntries(
|
|
65
|
+
entries: readonly QtiPackageEntry[],
|
|
66
|
+
diagnostics: QtiDiagnostic[],
|
|
67
|
+
): QtiPackageParseResult {
|
|
68
|
+
const entriesByPath = indexEntries(entries, diagnostics);
|
|
69
|
+
const xmlFilesByPath = parseXmlFiles(entriesByPath);
|
|
70
|
+
const manifestXml = xmlFilesByPath.get(QTI_PACKAGE_MANIFEST_PATH);
|
|
71
|
+
|
|
72
|
+
if (!manifestXml) {
|
|
73
|
+
pushPackageDiagnostic(
|
|
74
|
+
diagnostics,
|
|
75
|
+
"package.manifest.missing",
|
|
76
|
+
"error",
|
|
77
|
+
"QTI package does not contain imsmanifest.xml.",
|
|
78
|
+
QTI_PACKAGE_MANIFEST_PATH,
|
|
79
|
+
);
|
|
80
|
+
} else {
|
|
81
|
+
pushXmlDiagnostics(manifestXml, diagnostics);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const manifestRoot = manifestXml?.root?.localName === "manifest" ? manifestXml.root : undefined;
|
|
85
|
+
if (manifestXml?.root && !manifestRoot) {
|
|
86
|
+
pushPackageDiagnostic(
|
|
87
|
+
diagnostics,
|
|
88
|
+
"package.manifest.root",
|
|
89
|
+
"error",
|
|
90
|
+
`Expected imsmanifest.xml root manifest, found ${manifestXml.root.localName}.`,
|
|
91
|
+
QTI_PACKAGE_MANIFEST_PATH,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const manifestResourceRecords = manifestRoot
|
|
96
|
+
? parseManifestResources(manifestRoot, entriesByPath, diagnostics)
|
|
97
|
+
: [];
|
|
98
|
+
const manifestResources = manifestResourceRecords;
|
|
99
|
+
const resourcesByIdentifier = indexManifestResources(manifestResourceRecords, diagnostics);
|
|
100
|
+
diagnoseManifestDependencyReferences(manifestResourceRecords, resourcesByIdentifier, diagnostics);
|
|
101
|
+
|
|
102
|
+
const itemResources = manifestResourceRecords.filter((resource) =>
|
|
103
|
+
isQtiItemResource(resource.type),
|
|
104
|
+
);
|
|
105
|
+
const assessmentTestResources = manifestResourceRecords.filter((resource) =>
|
|
106
|
+
isQtiAssessmentTestResource(resource.type),
|
|
107
|
+
);
|
|
108
|
+
diagnosePrimaryResourceHrefs([...itemResources, ...assessmentTestResources], diagnostics);
|
|
109
|
+
|
|
110
|
+
const packageShape = detectPackageShape(itemResources, assessmentTestResources, diagnostics);
|
|
111
|
+
const assessmentTest = parseAssessmentTestPackageModel(
|
|
112
|
+
packageShape,
|
|
113
|
+
assessmentTestResources,
|
|
114
|
+
entriesByPath,
|
|
115
|
+
xmlFilesByPath,
|
|
116
|
+
diagnostics,
|
|
117
|
+
);
|
|
118
|
+
const itemReferences = itemReferencesForPackageShape(
|
|
119
|
+
packageShape,
|
|
120
|
+
itemResources,
|
|
121
|
+
assessmentTest,
|
|
122
|
+
diagnostics,
|
|
123
|
+
);
|
|
124
|
+
const items = parsePackageItems(itemReferences, entriesByPath, xmlFilesByPath, diagnostics);
|
|
125
|
+
const assets = collectPackageAssets(
|
|
126
|
+
resourcesByIdentifier,
|
|
127
|
+
assessmentTest,
|
|
128
|
+
items,
|
|
129
|
+
entriesByPath,
|
|
130
|
+
diagnostics,
|
|
131
|
+
);
|
|
132
|
+
const manifestStandards = manifestRoot
|
|
133
|
+
? parseStandardAlignments(manifestRoot, QTI_PACKAGE_MANIFEST_PATH)
|
|
134
|
+
: [];
|
|
135
|
+
const standards = uniqueStandards([
|
|
136
|
+
...manifestStandards,
|
|
137
|
+
...(assessmentTest?.standards ?? []),
|
|
138
|
+
...items.flatMap((item) => item.standards),
|
|
139
|
+
]);
|
|
140
|
+
const timing = primaryTiming(assessmentTest, items);
|
|
141
|
+
const title = packageTitle(manifestRoot, assessmentTest, items);
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
ok: diagnostics.every((diagnostic) => diagnostic.severity !== "error"),
|
|
145
|
+
title,
|
|
146
|
+
packageShape,
|
|
147
|
+
items,
|
|
148
|
+
assets,
|
|
149
|
+
manifestResources,
|
|
150
|
+
assessmentTest,
|
|
151
|
+
timing,
|
|
152
|
+
standards,
|
|
153
|
+
diagnostics,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function indexEntries(
|
|
158
|
+
entries: readonly QtiPackageEntry[],
|
|
159
|
+
diagnostics: QtiDiagnostic[],
|
|
160
|
+
): Map<string, QtiPackageEntry> {
|
|
161
|
+
const entriesByPath = new Map<string, QtiPackageEntry>();
|
|
162
|
+
for (const entry of entries) {
|
|
163
|
+
if (entriesByPath.has(entry.path)) {
|
|
164
|
+
pushPackageDiagnostic(
|
|
165
|
+
diagnostics,
|
|
166
|
+
"package.entry.duplicate",
|
|
167
|
+
"error",
|
|
168
|
+
`QTI package contains duplicate entry ${entry.path}.`,
|
|
169
|
+
entry.path,
|
|
170
|
+
);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
entriesByPath.set(entry.path, entry);
|
|
174
|
+
}
|
|
175
|
+
return entriesByPath;
|
|
176
|
+
}
|