@conform-ed/qti-xml 0.0.16

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.
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Universal QTI serialization dispatch: given a (version, schema-selection-key) and a
3
+ * normalized document, emit the spec-valid XML instance against the matching binding.
4
+ * The inverse of normalizeQtiDocument — delegating to the per-binding serializers
5
+ * (ASI, manifest, results, usage data, PNP), each gated by its own export round trip.
6
+ */
7
+
8
+ import {
9
+ serializeQtiAssessmentItem,
10
+ serializeQtiAssessmentSection,
11
+ serializeQtiAssessmentStimulus,
12
+ serializeQtiAssessmentTest,
13
+ serializeQtiOutcomeDeclarationDocument,
14
+ serializeQtiOutcomeProcessingDocument,
15
+ serializeQtiResponseProcessingDocument,
16
+ } from "./serialize-asi";
17
+ import { serializeQtiManifest, serializeQtiMetadata } from "./serialize-manifest";
18
+ import { serializeQtiAccessForAllPnp, serializeQtiAccessForAllPnpRecords } from "./serialize-pnp";
19
+ import { serializeQtiAssessmentResult } from "./serialize-result";
20
+ import { serializeQtiUsageData } from "./serialize-usage-data";
21
+ import type { QtiSchemaSelectionKey, QtiVersion } from "./types";
22
+
23
+ /** Roots with a serializer registered (the export-conformant direction). */
24
+ export function isSerializationImplemented(version: QtiVersion, key: QtiSchemaSelectionKey): boolean {
25
+ if (version === "2.2") {
26
+ // The v2.2 usage data binding is structurally identical to the v3 one.
27
+ return key === "qtiUsageDataDocument";
28
+ }
29
+ switch (key) {
30
+ case "qtiAssessmentItemDocument":
31
+ case "qtiAssessmentTestDocument":
32
+ case "qtiAssessmentStimulusDocument":
33
+ case "qtiAssessmentSectionDocument":
34
+ case "qtiResponseProcessingDocument":
35
+ case "qtiOutcomeDeclarationDocument":
36
+ case "qtiOutcomeProcessingDocument":
37
+ case "qtiMetadataDocument":
38
+ case "qtiManifestDocument":
39
+ case "qtiAssessmentResultDocument":
40
+ case "qtiUsageDataDocument":
41
+ case "qtiAccessForAllPnpDocument":
42
+ case "qtiAccessForAllPnpRecordsDocument":
43
+ return true;
44
+ default:
45
+ return false;
46
+ }
47
+ }
48
+
49
+ export function serializeQtiDocument(version: QtiVersion, key: QtiSchemaSelectionKey, document: unknown): string {
50
+ if (version === "2.2") {
51
+ if (key === "qtiUsageDataDocument") {
52
+ return serializeQtiUsageData(document as never);
53
+ }
54
+ throw new Error(`Serialization is not implemented for ${version} ${key}.`);
55
+ }
56
+
57
+ switch (key) {
58
+ case "qtiAssessmentItemDocument":
59
+ return serializeQtiAssessmentItem(document);
60
+ case "qtiAssessmentTestDocument":
61
+ return serializeQtiAssessmentTest(document);
62
+ case "qtiAssessmentStimulusDocument":
63
+ return serializeQtiAssessmentStimulus(document);
64
+ case "qtiAssessmentSectionDocument":
65
+ return serializeQtiAssessmentSection(document);
66
+ case "qtiResponseProcessingDocument":
67
+ return serializeQtiResponseProcessingDocument(document);
68
+ case "qtiOutcomeDeclarationDocument":
69
+ return serializeQtiOutcomeDeclarationDocument(document);
70
+ case "qtiOutcomeProcessingDocument":
71
+ return serializeQtiOutcomeProcessingDocument(document);
72
+ case "qtiMetadataDocument":
73
+ return serializeQtiMetadata(document);
74
+ case "qtiManifestDocument":
75
+ return serializeQtiManifest(document);
76
+ case "qtiAssessmentResultDocument":
77
+ return serializeQtiAssessmentResult(document as never);
78
+ case "qtiUsageDataDocument":
79
+ return serializeQtiUsageData(document as never);
80
+ case "qtiAccessForAllPnpDocument":
81
+ return serializeQtiAccessForAllPnp(document as never);
82
+ case "qtiAccessForAllPnpRecordsDocument":
83
+ return serializeQtiAccessForAllPnpRecords(document as never);
84
+ default:
85
+ // Exhaustive for v3: `key` is `never` here, so every binding the normalizer
86
+ // reads has a writer. A future key lands as a compile error on this line.
87
+ throw new Error(`Serialization is not implemented for ${version} ${String(key)}.`);
88
+ }
89
+ }
@@ -0,0 +1,215 @@
1
+ /**
2
+ * QTI 3 content-package manifest + qtiMetadata XML serialization — the export
3
+ * direction of the packaging binding (imscp_v1p1) and the metadata binding
4
+ * (imsqti_metadata_v3p0). Inverse of normalizeQti301Manifest / mapV3QtiMetadata;
5
+ * IEEE LOM rides through as a structurally preserved foreign-XML node. Gated by the
6
+ * corpus model round trip.
7
+ */
8
+
9
+ import { XmlWriter, type AttributeValue } from "./xml-writer";
10
+
11
+ const manifestNamespace = "http://www.imsglobal.org/xsd/qti/qtiv3p0/imscp_v1p1";
12
+ const metadataNamespace = "http://www.imsglobal.org/xsd/imsqti_metadata_v3p0";
13
+
14
+ type Node = Record<string, unknown>;
15
+ type Attrs = ReadonlyArray<readonly [string, AttributeValue]>;
16
+
17
+ function asNode(value: unknown): Node {
18
+ return (value ?? {}) as Node;
19
+ }
20
+
21
+ function str(node: Node, key: string): string | undefined {
22
+ const value = node[key];
23
+ return typeof value === "string" ? value : undefined;
24
+ }
25
+
26
+ function nodes(node: Node, key: string): Node[] {
27
+ const value = node[key];
28
+ return Array.isArray(value) ? value.map((entry) => asNode(entry)) : [];
29
+ }
30
+
31
+ function texts(node: Node, key: string): string[] {
32
+ const value = node[key];
33
+ return Array.isArray(value) ? value.map((entry) => String(entry)) : [];
34
+ }
35
+
36
+ function boolText(node: Node, key: string): string | undefined {
37
+ const value = node[key];
38
+ return typeof value === "boolean" ? String(value) : undefined;
39
+ }
40
+
41
+ /** A foreign-XML node (the preserved LOM): redeclare its namespace and recurse. */
42
+ function writeForeignNode(writer: XmlWriter, node: Node, ambient: string): void {
43
+ const name = str(node, "name") ?? "span";
44
+ const namespace = str(node, "namespace");
45
+ const attributes: Array<readonly [string, AttributeValue]> = Object.entries(
46
+ (node["attributes"] ?? {}) as Record<string, string>,
47
+ );
48
+
49
+ let childAmbient = ambient;
50
+ if (namespace !== undefined && namespace !== ambient) {
51
+ attributes.push(["xmlns", namespace]);
52
+ childAmbient = namespace;
53
+ }
54
+
55
+ const children = node["children"];
56
+ if (Array.isArray(children) && children.length) {
57
+ writer.element(name, attributes, () => {
58
+ for (const child of children) {
59
+ if (typeof child === "string") {
60
+ writer.text(child);
61
+ } else {
62
+ writeForeignNode(writer, asNode(child), childAmbient);
63
+ }
64
+ }
65
+ });
66
+ return;
67
+ }
68
+
69
+ const value = str(node, "value");
70
+ if (value !== undefined) {
71
+ writer.element(name, attributes, value);
72
+ return;
73
+ }
74
+
75
+ writer.element(name, attributes);
76
+ }
77
+
78
+ /** The qtiMetadata camelCase binding, shared by manifests and standalone documents. */
79
+ function writeQtiMetadataBody(writer: XmlWriter, metadata: Node): void {
80
+ const itemTemplate = boolText(metadata, "itemTemplate");
81
+ if (itemTemplate !== undefined) {
82
+ writer.element("itemTemplate", [], itemTemplate);
83
+ }
84
+ const timeDependent = boolText(metadata, "timeDependent");
85
+ if (timeDependent !== undefined) {
86
+ writer.element("timeDependent", [], timeDependent);
87
+ }
88
+ const composite = boolText(metadata, "composite");
89
+ if (composite !== undefined) {
90
+ writer.element("composite", [], composite);
91
+ }
92
+ for (const interactionType of texts(metadata, "interactionType")) {
93
+ writer.element("interactionType", [], interactionType);
94
+ }
95
+ const pciContext = metadata["portableCustomInteractionContext"];
96
+ if (pciContext) {
97
+ const context = asNode(pciContext);
98
+ writer.element("portableCustomInteractionContext", [], () => {
99
+ const customTypeIdentifier = str(context, "customTypeIdentifier");
100
+ if (customTypeIdentifier !== undefined) {
101
+ writer.element("customTypeIdentifier", [], customTypeIdentifier);
102
+ }
103
+ const interactionKind = str(context, "interactionKind");
104
+ if (interactionKind !== undefined) {
105
+ writer.element("interactionKind", [], interactionKind);
106
+ }
107
+ });
108
+ }
109
+ const feedbackType = str(metadata, "feedbackType");
110
+ if (feedbackType !== undefined) {
111
+ writer.element("feedbackType", [], feedbackType);
112
+ }
113
+ const solutionAvailable = boolText(metadata, "solutionAvailable");
114
+ if (solutionAvailable !== undefined) {
115
+ writer.element("solutionAvailable", [], solutionAvailable);
116
+ }
117
+ for (const scoringMode of texts(metadata, "scoringMode")) {
118
+ writer.element("scoringMode", [], scoringMode);
119
+ }
120
+ const toolName = str(metadata, "toolName");
121
+ if (toolName !== undefined) {
122
+ writer.element("toolName", [], toolName);
123
+ }
124
+ const toolVersion = str(metadata, "toolVersion");
125
+ if (toolVersion !== undefined) {
126
+ writer.element("toolVersion", [], toolVersion);
127
+ }
128
+ const toolVendor = str(metadata, "toolVendor");
129
+ if (toolVendor !== undefined) {
130
+ writer.element("toolVendor", [], toolVendor);
131
+ }
132
+ }
133
+
134
+ /** The qtiMetadata + LOM pair carried by manifest and resource metadata. */
135
+ function writeResourceMetadataBody(writer: XmlWriter, metadata: Node, ambient: string): void {
136
+ const qtiMetadata = metadata["qtiMetadata"];
137
+ if (qtiMetadata) {
138
+ writer.element("qtiMetadata", [], () => writeQtiMetadataBody(writer, asNode(qtiMetadata)));
139
+ }
140
+ const lom = metadata["lom"];
141
+ if (lom) {
142
+ writeForeignNode(writer, asNode(lom), ambient);
143
+ }
144
+ }
145
+
146
+ /** Serialize a standalone qtiMetadata document. */
147
+ export function serializeQtiMetadata(document: unknown): string {
148
+ const metadata = asNode(asNode(document)["qtiMetadata"]);
149
+ const writer = new XmlWriter();
150
+ writer.line('<?xml version="1.0" encoding="UTF-8"?>');
151
+
152
+ writer.element(
153
+ "qtiMetadata",
154
+ [
155
+ ["xmlns", metadataNamespace],
156
+ ["xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance"],
157
+ ],
158
+ () => writeQtiMetadataBody(writer, metadata),
159
+ );
160
+
161
+ return writer.toString();
162
+ }
163
+
164
+ /** Serialize a QTI 3 content-package manifest. */
165
+ export function serializeQtiManifest(document: unknown): string {
166
+ const manifest = asNode(asNode(document)["manifest"]);
167
+ const metadata = asNode(manifest["metadata"]);
168
+ const writer = new XmlWriter();
169
+ writer.line('<?xml version="1.0" encoding="UTF-8"?>');
170
+
171
+ const rootAttributes: Attrs = [
172
+ ["xmlns", manifestNamespace],
173
+ ["xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance"],
174
+ ["identifier", str(manifest, "identifier")],
175
+ ];
176
+
177
+ writer.element("manifest", rootAttributes, () => {
178
+ writer.element("metadata", [], () => {
179
+ writer.element("schema", [], str(metadata, "schema") ?? "");
180
+ writer.element("schemaversion", [], str(metadata, "schemaVersion") ?? "");
181
+ writeResourceMetadataBody(writer, metadata, manifestNamespace);
182
+ });
183
+
184
+ writer.element("organizations", []);
185
+
186
+ writer.element("resources", [], () => {
187
+ for (const resource of nodes(manifest, "resources")) {
188
+ writer.element(
189
+ "resource",
190
+ [
191
+ ["identifier", str(resource, "identifier")],
192
+ ["type", str(resource, "type")],
193
+ ["href", str(resource, "href")],
194
+ ],
195
+ () => {
196
+ const resourceMetadata = resource["metadata"];
197
+ if (resourceMetadata) {
198
+ writer.element("metadata", [], () =>
199
+ writeResourceMetadataBody(writer, asNode(resourceMetadata), manifestNamespace),
200
+ );
201
+ }
202
+ for (const file of nodes(resource, "files")) {
203
+ writer.element("file", [["href", str(file, "href")]]);
204
+ }
205
+ for (const dependency of nodes(resource, "dependencies")) {
206
+ writer.element("dependency", [["identifierref", str(dependency, "identifierRef")]]);
207
+ }
208
+ },
209
+ );
210
+ }
211
+ });
212
+ });
213
+
214
+ return writer.toString();
215
+ }
@@ -0,0 +1,385 @@
1
+ /**
2
+ * AfA PNP (QTI 3.0 profile) XML serialization (imsqtiv3p0_afa3p0pnp_v1p0) — the
3
+ * export direction of the candidate-preferences binding, the exact inverse of the
4
+ * import normalizer; gated in tests by the round trip through our own parser,
5
+ * normalizer, and strict contracts schema.
6
+ */
7
+
8
+ import type {
9
+ QtiAccessForAllPnp,
10
+ QtiAccessForAllPnpDocument,
11
+ QtiAccessForAllPnpRecordsDocument,
12
+ QtiPnpFeatureSet,
13
+ QtiPnpReplaceAccessMode,
14
+ } from "@conform-ed/contracts/qti/v3_0_1";
15
+
16
+ import { XmlWriter, type AttributeValue } from "./xml-writer";
17
+
18
+ const pnpNamespace = "http://www.imsglobal.org/xsd/qti/qtiv3p0/imsafa3p0pnp_v1p0";
19
+ const pnpSchemaLocation = `${pnpNamespace} https://purl.imsglobal.org/spec/qti/v3p0/schema/xsd/imsqtiv3p0_afa3p0pnp_v1p0.xsd`;
20
+
21
+ function writeReplaceModes(writer: XmlWriter, view: QtiPnpReplaceAccessMode | undefined): void {
22
+ for (const mode of view?.replaceAccessModes ?? []) {
23
+ writer.element(`replace-access-mode-${mode}`, []);
24
+ }
25
+ }
26
+
27
+ /** A ReplacesAccessMode container: empty element, or replace-mode children. */
28
+ function replaceModeElement(
29
+ writer: XmlWriter,
30
+ name: string,
31
+ view: QtiPnpReplaceAccessMode | undefined,
32
+ attributes: ReadonlyArray<readonly [string, AttributeValue]> = [],
33
+ body?: () => void,
34
+ ): void {
35
+ if (!view) {
36
+ return;
37
+ }
38
+
39
+ const hasChildren = (view.replaceAccessModes?.length ?? 0) > 0 || body !== undefined;
40
+
41
+ if (!hasChildren) {
42
+ writer.element(name, attributes);
43
+ return;
44
+ }
45
+
46
+ writer.element(name, attributes, () => {
47
+ writeReplaceModes(writer, view);
48
+ body?.();
49
+ });
50
+ }
51
+
52
+ function featureSetElement(writer: XmlWriter, name: string, view: QtiPnpFeatureSet | undefined): void {
53
+ if (!view) {
54
+ return;
55
+ }
56
+
57
+ if (!view.features?.length) {
58
+ writer.element(name, []);
59
+ return;
60
+ }
61
+
62
+ writer.element(name, [], () => {
63
+ for (const feature of view.features ?? []) {
64
+ writer.element(feature, []);
65
+ }
66
+ });
67
+ }
68
+
69
+ function writePnpBody(writer: XmlWriter, pnp: QtiAccessForAllPnp): void {
70
+ for (const hazard of pnp.hazardAvoidance ?? []) {
71
+ writer.element("hazard-avoidance", [], hazard);
72
+ }
73
+
74
+ if (pnp.inputRequirements !== undefined) {
75
+ writer.element("input-requirements", [], pnp.inputRequirements);
76
+ }
77
+
78
+ for (const language of pnp.languageOfInterface ?? []) {
79
+ replaceModeElement(writer, "language-of-interface", language, [["xml:lang", language.xmlLang]]);
80
+ }
81
+
82
+ replaceModeElement(writer, "linguistic-guidance", pnp.linguisticGuidance);
83
+ replaceModeElement(writer, "keyword-emphasis", pnp.keywordEmphasis);
84
+ replaceModeElement(
85
+ writer,
86
+ "keyword-translation",
87
+ pnp.keywordTranslation,
88
+ pnp.keywordTranslation ? [["xml:lang", pnp.keywordTranslation.xmlLang]] : [],
89
+ );
90
+ replaceModeElement(writer, "simplified-language-portions", pnp.simplifiedLanguagePortions);
91
+ replaceModeElement(writer, "simplified-graphics", pnp.simplifiedGraphics);
92
+ replaceModeElement(
93
+ writer,
94
+ "item-translation",
95
+ pnp.itemTranslation,
96
+ pnp.itemTranslation ? [["xml:lang", pnp.itemTranslation.xmlLang]] : [],
97
+ );
98
+ replaceModeElement(
99
+ writer,
100
+ "sign-language",
101
+ pnp.signLanguage,
102
+ pnp.signLanguage ? [["xml:lang", pnp.signLanguage.xmlLang]] : [],
103
+ );
104
+ replaceModeElement(writer, "encouragement", pnp.encouragement);
105
+
106
+ const time = pnp.additionalTestingTime;
107
+ if (time) {
108
+ replaceModeElement(writer, "additional-testing-time", time, [], () => {
109
+ if (time.timeMultiplier !== undefined) {
110
+ writer.element("time-multiplier", [], String(time.timeMultiplier));
111
+ }
112
+ if (time.fixedMinutes !== undefined) {
113
+ writer.element("fixed-minutes", [], String(time.fixedMinutes));
114
+ }
115
+ if (time.unlimited === true) {
116
+ writer.element("unlimited", []);
117
+ }
118
+ });
119
+ }
120
+
121
+ replaceModeElement(
122
+ writer,
123
+ "line-reader",
124
+ pnp.lineReader,
125
+ pnp.lineReader ? [["highlight-color", pnp.lineReader.highlightColor]] : [],
126
+ );
127
+ replaceModeElement(
128
+ writer,
129
+ "invert-display-polarity",
130
+ pnp.invertDisplayPolarity,
131
+ pnp.invertDisplayPolarity
132
+ ? [
133
+ ["foreground", pnp.invertDisplayPolarity.foreground],
134
+ ["background", pnp.invertDisplayPolarity.background],
135
+ ]
136
+ : [],
137
+ );
138
+
139
+ const magnification = pnp.magnification;
140
+ if (magnification) {
141
+ replaceModeElement(writer, "magnification", magnification, [], () => {
142
+ if (magnification.allContent) {
143
+ writer.element("all-content", [["zoom-amount", magnification.allContent.zoomAmount]]);
144
+ }
145
+ if (magnification.text) {
146
+ writer.element("text", [["zoom-amount", magnification.text.zoomAmount]]);
147
+ }
148
+ if (magnification.nonText) {
149
+ writer.element("non-text", [["zoom-amount", magnification.nonText.zoomAmount]]);
150
+ }
151
+ });
152
+ }
153
+
154
+ const spoken = pnp.spoken;
155
+ if (spoken) {
156
+ replaceModeElement(writer, "spoken", spoken, [], () => {
157
+ if (spoken.readingType !== undefined) {
158
+ writer.element("reading-type", [], spoken.readingType);
159
+ }
160
+ for (const restriction of spoken.restrictionTypes ?? []) {
161
+ writer.element("restriction-type", [], restriction);
162
+ }
163
+ if (spoken.speechRate !== undefined) {
164
+ writer.element("speech-rate", [], String(spoken.speechRate));
165
+ }
166
+ if (spoken.pitch !== undefined) {
167
+ writer.element("pitch", [], String(spoken.pitch));
168
+ }
169
+ if (spoken.volume !== undefined) {
170
+ writer.element("volume", [], String(spoken.volume));
171
+ }
172
+ if (spoken.linkIndication !== undefined) {
173
+ writer.element("link-indication", [], spoken.linkIndication);
174
+ }
175
+ if (spoken.typingEcho !== undefined) {
176
+ writer.element("typing-echo", [], spoken.typingEcho);
177
+ }
178
+ });
179
+ }
180
+
181
+ replaceModeElement(writer, "tactile", pnp.tactile);
182
+
183
+ const braille = pnp.braille;
184
+ if (braille) {
185
+ const hasChildren =
186
+ braille.deliveryMode !== undefined ||
187
+ braille.grade !== undefined ||
188
+ braille.brailleType !== undefined ||
189
+ braille.mathType !== undefined;
190
+
191
+ replaceModeElement(
192
+ writer,
193
+ "braille",
194
+ braille,
195
+ [["xml:lang", braille.xmlLang]],
196
+ hasChildren
197
+ ? () => {
198
+ if (braille.deliveryMode !== undefined) {
199
+ writer.element("delivery-mode", [], braille.deliveryMode);
200
+ }
201
+ if (braille.grade !== undefined) {
202
+ writer.element("grade", [], braille.grade);
203
+ }
204
+ if (braille.brailleType !== undefined) {
205
+ writer.element("braille-type", [], braille.brailleType);
206
+ }
207
+ if (braille.mathType !== undefined) {
208
+ writer.element("math-type", [], braille.mathType);
209
+ }
210
+ }
211
+ : undefined,
212
+ );
213
+ }
214
+
215
+ replaceModeElement(writer, "answer-masking", pnp.answerMasking);
216
+ replaceModeElement(writer, "keyboard-directions", pnp.keyboardDirections);
217
+ replaceModeElement(writer, "additional-directions", pnp.additionalDirections);
218
+ replaceModeElement(
219
+ writer,
220
+ "long-description",
221
+ pnp.longDescription,
222
+ pnp.longDescription ? [["hide-visually", pnp.longDescription.hideVisually]] : [],
223
+ );
224
+ replaceModeElement(writer, "captions", pnp.captions);
225
+
226
+ const environment = pnp.environment;
227
+ if (environment) {
228
+ const hasChildren =
229
+ environment.description !== undefined ||
230
+ environment.medical !== undefined ||
231
+ environment.software !== undefined ||
232
+ environment.hardware !== undefined ||
233
+ environment.breaks !== undefined;
234
+
235
+ replaceModeElement(
236
+ writer,
237
+ "environment",
238
+ environment,
239
+ [],
240
+ hasChildren
241
+ ? () => {
242
+ if (environment.description !== undefined) {
243
+ writer.element("description", [], environment.description);
244
+ }
245
+ if (environment.medical !== undefined) {
246
+ writer.element("medical", [], environment.medical);
247
+ }
248
+ if (environment.software !== undefined) {
249
+ writer.element("software", [], environment.software);
250
+ }
251
+ if (environment.hardware !== undefined) {
252
+ writer.element("hardware", [], environment.hardware);
253
+ }
254
+ if (environment.breaks !== undefined) {
255
+ writer.element("breaks", [], String(environment.breaks));
256
+ }
257
+ }
258
+ : undefined,
259
+ );
260
+ }
261
+
262
+ replaceModeElement(writer, "transcript", pnp.transcript);
263
+ replaceModeElement(writer, "alternative-text", pnp.alternativeText);
264
+ replaceModeElement(writer, "audio-description", pnp.audioDescription);
265
+ replaceModeElement(writer, "high-contrast", pnp.highContrast);
266
+ replaceModeElement(writer, "layout-single-column", pnp.layoutSingleColumn);
267
+
268
+ const textAppearance = pnp.textAppearance;
269
+ if (textAppearance) {
270
+ replaceModeElement(writer, "text-appearance", textAppearance, [], () => {
271
+ if (textAppearance.backgroundColor !== undefined) {
272
+ writer.element("background-color", [], textAppearance.backgroundColor);
273
+ }
274
+ if (textAppearance.fontColor !== undefined) {
275
+ writer.element("font-color", [], textAppearance.fontColor);
276
+ }
277
+ if (textAppearance.fontSize !== undefined) {
278
+ writer.element("font-size", [], String(textAppearance.fontSize));
279
+ }
280
+ if (textAppearance.fontFace) {
281
+ writer.element("font-face", [], () => {
282
+ for (const fontName of textAppearance.fontFace?.fontName ?? []) {
283
+ writer.element("font-name", [], fontName);
284
+ }
285
+ if (textAppearance.fontFace?.genericFontFace !== undefined) {
286
+ writer.element("generic-font-face", [], textAppearance.fontFace.genericFontFace);
287
+ }
288
+ });
289
+ }
290
+ if (textAppearance.lineSpacing !== undefined) {
291
+ writer.element("line-spacing", [], String(textAppearance.lineSpacing));
292
+ }
293
+ if (textAppearance.lineHeight !== undefined) {
294
+ writer.element("line-height", [], String(textAppearance.lineHeight));
295
+ }
296
+ if (textAppearance.letterSpacing !== undefined) {
297
+ writer.element("letter-spacing", [], String(textAppearance.letterSpacing));
298
+ }
299
+ if (textAppearance.uniformFontSizing === true) {
300
+ writer.element("uniform-font-sizing", []);
301
+ }
302
+ if (textAppearance.wordSpacing !== undefined) {
303
+ writer.element("word-spacing", [], String(textAppearance.wordSpacing));
304
+ }
305
+ if (textAppearance.wordWrapping === true) {
306
+ writer.element("word-wrapping", []);
307
+ }
308
+ });
309
+ }
310
+
311
+ if (pnp.calculatorOnScreen) {
312
+ writer.element("calculator-on-screen", [["calculator-type", pnp.calculatorOnScreen.calculatorType]]);
313
+ }
314
+
315
+ const onScreenFlags: ReadonlyArray<readonly [string, boolean | undefined]> = [
316
+ ["dictionary-on-screen", pnp.dictionaryOnScreen],
317
+ ["glossary-on-screen", pnp.glossaryOnScreen],
318
+ ["thesaurus-on-screen", pnp.thesaurusOnScreen],
319
+ ["homophone-checker-on-screen", pnp.homophoneCheckerOnScreen],
320
+ ["note-taking-on-screen", pnp.noteTakingOnScreen],
321
+ ["visual-organizer-on-screen", pnp.visualOrganizerOnScreen],
322
+ ["outliner-on-screen", pnp.outlinerOnScreen],
323
+ ["peer-interaction-on-screen", pnp.peerInteractionOnScreen],
324
+ ["spell-checker-on-screen", pnp.spellCheckerOnScreen],
325
+ ];
326
+ for (const [name, enabled] of onScreenFlags) {
327
+ if (enabled === true) {
328
+ writer.element(name, []);
329
+ }
330
+ }
331
+
332
+ featureSetElement(writer, "activate-at-initialization-set", pnp.activateAtInitializationSet);
333
+ featureSetElement(writer, "activate-as-option-set", pnp.activateAsOptionSet);
334
+ featureSetElement(writer, "prohibit-set", pnp.prohibitSet);
335
+ }
336
+
337
+ /** Serialize a candidate-preferences document against the official QTI 3 profile binding. */
338
+ export function serializeQtiAccessForAllPnp(document: QtiAccessForAllPnpDocument): string {
339
+ const writer = new XmlWriter();
340
+
341
+ writer.line('<?xml version="1.0" encoding="UTF-8"?>');
342
+ writer.element(
343
+ "access-for-all-pnp",
344
+ [
345
+ ["xmlns", pnpNamespace],
346
+ ["xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance"],
347
+ ["xsi:schemaLocation", pnpSchemaLocation],
348
+ ],
349
+ () => writePnpBody(writer, document.accessForAllPnp),
350
+ );
351
+
352
+ return writer.toString();
353
+ }
354
+
355
+ /** Serialize a person-keyed PNP records document. */
356
+ export function serializeQtiAccessForAllPnpRecords(document: QtiAccessForAllPnpRecordsDocument): string {
357
+ const writer = new XmlWriter();
358
+
359
+ writer.line('<?xml version="1.0" encoding="UTF-8"?>');
360
+ writer.element(
361
+ "access-for-all-pnp-records",
362
+ [
363
+ ["xmlns", pnpNamespace],
364
+ ["xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance"],
365
+ ["xsi:schemaLocation", pnpSchemaLocation],
366
+ ],
367
+ () => {
368
+ for (const record of document.accessForAllPnpRecords.records) {
369
+ writer.element("access-for-all-pnp-record", [], () => {
370
+ writer.element(
371
+ "person-sourced-id",
372
+ [["source-system", record.personSourcedId.sourceSystem]],
373
+ record.personSourcedId.value,
374
+ );
375
+ for (const appointment of record.appointmentId ?? []) {
376
+ writer.element("appointment-id", [], appointment);
377
+ }
378
+ writer.element("access-for-all-pnp", [], () => writePnpBody(writer, record.accessForAllPnp));
379
+ });
380
+ }
381
+ },
382
+ );
383
+
384
+ return writer.toString();
385
+ }