@longsightgroup/qti3-core 0.7.1 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/content-text.d.ts +8 -0
  2. package/dist/content-text.d.ts.map +1 -0
  3. package/dist/content-text.js +44 -0
  4. package/dist/content-text.js.map +1 -0
  5. package/dist/index.d.ts +2 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +2 -1
  8. package/dist/index.js.map +1 -1
  9. package/dist/parser.d.ts.map +1 -1
  10. package/dist/parser.js +18 -4
  11. package/dist/parser.js.map +1 -1
  12. package/dist/pattern-mask.d.ts +4 -0
  13. package/dist/pattern-mask.d.ts.map +1 -0
  14. package/dist/pattern-mask.js +22 -0
  15. package/dist/pattern-mask.js.map +1 -0
  16. package/dist/shared-vocabulary-generated-families.js +1 -1
  17. package/dist/shared-vocabulary-generated-families.js.map +1 -1
  18. package/dist/shared-vocabulary-support.d.ts.map +1 -1
  19. package/dist/shared-vocabulary-support.js +5 -2
  20. package/dist/shared-vocabulary-support.js.map +1 -1
  21. package/dist/shared-vocabulary-validation.d.ts +1 -0
  22. package/dist/shared-vocabulary-validation.d.ts.map +1 -1
  23. package/dist/shared-vocabulary-validation.js +10 -1
  24. package/dist/shared-vocabulary-validation.js.map +1 -1
  25. package/dist/shared-vocabulary.d.ts +12 -0
  26. package/dist/shared-vocabulary.d.ts.map +1 -1
  27. package/dist/shared-vocabulary.js +26 -0
  28. package/dist/shared-vocabulary.js.map +1 -1
  29. package/dist/support.js +40 -2
  30. package/dist/support.js.map +1 -1
  31. package/dist/types.d.ts +6 -0
  32. package/dist/types.d.ts.map +1 -1
  33. package/dist/validation.d.ts.map +1 -1
  34. package/dist/validation.js +17 -0
  35. package/dist/validation.js.map +1 -1
  36. package/dist/xml.d.ts.map +1 -1
  37. package/dist/xml.js +24 -1
  38. package/dist/xml.js.map +1 -1
  39. package/package.json +1 -1
  40. package/src/content-text.ts +61 -0
  41. package/src/index.ts +6 -0
  42. package/src/parser.ts +24 -4
  43. package/src/pattern-mask.ts +20 -0
  44. package/src/shared-vocabulary-generated-families.ts +1 -1
  45. package/src/shared-vocabulary-support.ts +5 -2
  46. package/src/shared-vocabulary-validation.ts +12 -1
  47. package/src/shared-vocabulary.ts +41 -0
  48. package/src/support.ts +42 -2
  49. package/src/types.ts +6 -0
  50. package/src/validation.ts +19 -0
  51. package/src/xml.ts +24 -1
package/src/parser.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { flatTextFromContent } from "./content-text.js";
1
2
  import { getInteractionSupport, interactionNameToType, processingSupport } from "./support.js";
2
3
  import type {
3
4
  QtiAssessmentItem,
@@ -480,13 +481,22 @@ function parseInteraction(
480
481
  (child) => child.localName === "object" || child.localName === "img",
481
482
  )[0];
482
483
 
484
+ const promptContent = prompt
485
+ ? parseContentChildren(prompt, diagnostics, responseDeclarationMap, [])
486
+ : undefined;
487
+
483
488
  return {
484
489
  type: interactionType ?? "custom",
485
490
  qtiName: node.localName,
486
491
  responseIdentifier,
487
492
  responseCardinality: responseDeclaration?.cardinality,
488
493
  responseBaseType: responseDeclaration?.baseType,
489
- prompt: prompt ? textContent(prompt) : undefined,
494
+ prompt: promptContent
495
+ ? flatTextFromContent(promptContent, { excludeAnnotations: true }) || undefined
496
+ : prompt
497
+ ? textContent(prompt)
498
+ : undefined,
499
+ promptContent: promptContent && promptContent.length > 0 ? promptContent : undefined,
490
500
  promptAttributes: prompt?.attributes,
491
501
  promptSource: prompt?.source,
492
502
  contextText: inlineInteractionContext(node, interactionType),
@@ -499,7 +509,7 @@ function parseInteraction(
499
509
  interactionType === "portableCustom"
500
510
  ? parsePortableCustomDefinition(node, diagnostics)
501
511
  : undefined,
502
- choices: parseChoices(node),
512
+ choices: parseChoices(node, diagnostics, responseDeclarationMap),
503
513
  hottextSegments: interactionType === "hottext" ? parseHottextSegments(node) : undefined,
504
514
  gapMatchSegments:
505
515
  interactionType === "gapMatch" || interactionType === "graphicGapMatch"
@@ -965,7 +975,11 @@ function assetTypeFromData(data: string | undefined): string | undefined {
965
975
  return undefined;
966
976
  }
967
977
 
968
- function parseChoices(node: XmlNode): QtiChoice[] {
978
+ function parseChoices(
979
+ node: XmlNode,
980
+ diagnostics: QtiDiagnostic[],
981
+ responseDeclarationMap: Map<string, QtiResponseDeclaration>,
982
+ ): QtiChoice[] {
969
983
  const choiceNames = new Set([
970
984
  "qti-simple-choice",
971
985
  "qti-simple-associable-choice",
@@ -981,14 +995,20 @@ function parseChoices(node: XmlNode): QtiChoice[] {
981
995
  return descendants(node, (child) => choiceNames.has(child.localName)).map((choice, index) => {
982
996
  const identifier = choice.attributes.identifier ?? "";
983
997
  const asset = parseChoiceAsset(choice);
998
+ const content = parseContentChildren(choice, diagnostics, responseDeclarationMap, []);
999
+ const flatChoiceText =
1000
+ content.length > 0
1001
+ ? flatTextFromContent(content, { excludeAnnotations: true })
1002
+ : textContent(choice);
984
1003
  return {
985
1004
  identifier,
986
1005
  text:
987
- textContent(choice) ||
1006
+ flatChoiceText ||
988
1007
  choice.attributes["object-label"] ||
989
1008
  asset?.text ||
990
1009
  identifier ||
991
1010
  `Choice ${index + 1}`,
1011
+ content: content.length > 0 ? content : undefined,
992
1012
  asset,
993
1013
  role: choiceRole(choice),
994
1014
  qtiName: choice.localName,
@@ -0,0 +1,20 @@
1
+ /** Compiles a QTI pattern-mask attribute value for full-string matching. */
2
+ export function compileQtiPatternMask(patternMask: string | undefined): RegExp | undefined {
3
+ if (patternMask === undefined) return undefined;
4
+ let mask = patternMask.trim();
5
+ if (mask.length === 0) return undefined;
6
+
7
+ mask = mask.startsWith("^") ? mask.slice(1) : mask;
8
+ mask = mask.endsWith("$") ? mask.slice(0, -1) : mask;
9
+ if (mask.length === 0) return undefined;
10
+
11
+ try {
12
+ return new RegExp(`^(?:${mask.replaceAll("/", "\\/")})$`);
13
+ } catch {
14
+ return undefined;
15
+ }
16
+ }
17
+
18
+ export function isValidQtiPatternMask(patternMask: string): boolean {
19
+ return compileQtiPatternMask(patternMask) !== undefined;
20
+ }
@@ -237,7 +237,7 @@ export const sharedVocabularyMatrixCoverageFamilies: SharedVocabularyMatrixCover
237
237
  levels: ["stylesheet"],
238
238
  coveredBy: ["qti-writing-orientation-vertical-rl"],
239
239
  rationale:
240
- "Choice writing-orientation classes share the vertical choice layout path; vertical-rl covers the orientation rule and upright labels.",
240
+ "Choice and inline-choice writing-orientation classes share vertical rendering paths; vertical-rl covers the representative orientation rule.",
241
241
  matches: (className) => choiceWritingOrientationPattern.test(className),
242
242
  },
243
243
  ];
@@ -39,7 +39,10 @@ const browserBehaviorTests = [
39
39
  "tests/browser/player-dom-behavior.spec.ts",
40
40
  ...sharedVocabularyMatrixTests,
41
41
  ];
42
- const graphicBrowserTests = ["tests/browser/player-graphic.spec.ts"];
42
+ const graphicBrowserTests = [
43
+ "tests/browser/player-graphic.spec.ts",
44
+ "tests/browser/player-graphic-gap-match.spec.ts",
45
+ ];
43
46
  const mediaBrowserTests = ["tests/browser/player.spec.ts", ...sharedVocabularyMatrixTests];
44
47
  const mediaPlayerFixture =
45
48
  "packages/fixtures/packages/sv-matrix/items/media-controls-and-pause.xml";
@@ -226,7 +229,7 @@ export const sharedVocabularyClassSupport: SharedVocabularyClassSupport[] = [
226
229
  ...SHARED_VOCABULARY_CHOICE_WRITING_ORIENTATIONS.map((orientation) =>
227
230
  interactionStylesheetEntry(
228
231
  `qti-writing-orientation-${orientation}`,
229
- ["choice"],
232
+ ["choice", "inlineChoice"],
230
233
  [...browserBehaviorTests],
231
234
  ),
232
235
  ),
@@ -97,6 +97,7 @@ export function validateSharedVocabularyInputWidth(
97
97
  export interface SharedVocabularyExtendedTextValidationOptions {
98
98
  classNames: string[];
99
99
  subjectQtiName: string;
100
+ expectedLength?: string | undefined;
100
101
  path?: string | undefined;
101
102
  source?: QtiSourceLocation | undefined;
102
103
  }
@@ -122,7 +123,7 @@ export function validateSharedVocabularyExtendedTextHeightLines(
122
123
  export function validateSharedVocabularyExtendedTextCounter(
123
124
  options: SharedVocabularyExtendedTextValidationOptions,
124
125
  ): QtiDiagnostic[] {
125
- const { classNames, subjectQtiName, path, source } = options;
126
+ const { classNames, subjectQtiName, expectedLength, path, source } = options;
126
127
  const diagnostics: QtiDiagnostic[] = [];
127
128
  const counterClasses = supportedExtendedTextCounterClassNames(classNames);
128
129
  if (new Set(counterClasses).size > 1) {
@@ -135,6 +136,16 @@ export function validateSharedVocabularyExtendedTextCounter(
135
136
  });
136
137
  }
137
138
 
139
+ if (counterClasses.length > 0 && !expectedLength) {
140
+ diagnostics.push({
141
+ code: "interaction.sharedVocabulary.extendedTextCounterExpectedLength",
142
+ severity: "warning",
143
+ message: `${subjectQtiName} uses ${[...new Set(counterClasses)].join(", ")} but does not define expected-length; qti-counter-* shared vocabulary depends on expected-length.`,
144
+ path,
145
+ source,
146
+ });
147
+ }
148
+
138
149
  for (const className of classNames) {
139
150
  if (!className.startsWith("qti-counter-")) continue;
140
151
  if (isSupportedExtendedTextCounterClassName(className)) continue;
@@ -106,6 +106,47 @@ export function extendedTextCounterPosition(
106
106
  return extendedTextCounterPositionFromAttributes(interaction.attributes);
107
107
  }
108
108
 
109
+ export function extendedTextExpectedLengthFromAttributes(
110
+ attributes: Record<string, string>,
111
+ ): number | undefined {
112
+ const raw = attributes["expected-length"];
113
+ if (raw === undefined) return undefined;
114
+ const length = Number(raw);
115
+ if (!Number.isFinite(length) || length < 0) return undefined;
116
+ return length;
117
+ }
118
+
119
+ export interface ExtendedTextCounterState {
120
+ position: SharedVocabularyExtendedTextCounterPosition;
121
+ expectedLength: number;
122
+ }
123
+
124
+ /** Counter chrome is authored only when both qti-counter-* and a valid expected-length are present. */
125
+ export function extendedTextCounterStateFromAttributes(
126
+ attributes: Record<string, string>,
127
+ ): ExtendedTextCounterState | undefined {
128
+ const position = extendedTextCounterPositionFromAttributes(attributes);
129
+ if (position === undefined) return undefined;
130
+ const expectedLength = extendedTextExpectedLengthFromAttributes(attributes);
131
+ if (expectedLength === undefined) return undefined;
132
+ return { position, expectedLength };
133
+ }
134
+
135
+ export function extendedTextCounterState(
136
+ interaction: QtiInteraction,
137
+ ): ExtendedTextCounterState | undefined {
138
+ return extendedTextCounterStateFromAttributes(interaction.attributes);
139
+ }
140
+
141
+ export function extendedTextCounterValues(
142
+ position: SharedVocabularyExtendedTextCounterPosition,
143
+ valueLength: number,
144
+ expectedLength: number,
145
+ ): { count: number; expectedLength: number } {
146
+ const count = position === "down" ? Math.max(0, expectedLength - valueLength) : valueLength;
147
+ return { count, expectedLength };
148
+ }
149
+
109
150
  export function supportedInputWidthClassNames(classNames: string[]): string[] {
110
151
  return classNames.filter((className) => {
111
152
  const value = inputWidthClassPattern.exec(className)?.[1];
package/src/support.ts CHANGED
@@ -10,7 +10,7 @@ export const interactionSupport: QtiInteractionElementSupport[] = [
10
10
  entry("qti-choice-interaction", "choice"),
11
11
  entry("qti-drawing-interaction", "drawing"),
12
12
  entry("qti-end-attempt-interaction", "endAttempt"),
13
- entry("qti-extended-text-interaction", "extendedText"),
13
+ extendedTextInteractionEntry(),
14
14
  entry("qti-gap-match-interaction", "gapMatch"),
15
15
  entry("qti-graphic-associate-interaction", "graphicAssociate"),
16
16
  entry("qti-graphic-gap-match-interaction", "graphicGapMatch"),
@@ -25,7 +25,7 @@ export const interactionSupport: QtiInteractionElementSupport[] = [
25
25
  pciEntry(),
26
26
  entry("qti-select-point-interaction", "selectPoint"),
27
27
  entry("qti-slider-interaction", "slider"),
28
- entry("qti-text-entry-interaction", "textEntry"),
28
+ textEntryInteractionEntry(),
29
29
  entry("qti-upload-interaction", "upload"),
30
30
  ];
31
31
 
@@ -225,6 +225,46 @@ function entry(qtiName: string, interactionType: QtiInteractionType): QtiInterac
225
225
  };
226
226
  }
227
227
 
228
+ function extendedTextInteractionEntry(): QtiInteractionElementSupport {
229
+ return {
230
+ ...entry("qti-extended-text-interaction", "extendedText"),
231
+ fixtures: [
232
+ "packages/fixtures/xml/extendedText-reference.xml",
233
+ "packages/fixtures/packages/sv-matrix/items/extended-text-pattern-mask.xml",
234
+ "packages/fixtures/packages/sv-matrix/items/extended-text-xhtml.xml",
235
+ ],
236
+ tests: [
237
+ "packages/fixtures/src/fixtures.test.ts",
238
+ "packages/conformance/src/conformance.test.ts",
239
+ "packages/a11y/src/a11y.test.ts",
240
+ "packages/core/src/pattern-mask.test.ts",
241
+ "tests/browser/player.spec.ts",
242
+ "tests/browser/player-dom-behavior.spec.ts",
243
+ "tests/browser/player-extended-text-xhtml.spec.ts",
244
+ ],
245
+ notes: "Supports plain and format=xhtml extended text.",
246
+ };
247
+ }
248
+
249
+ function textEntryInteractionEntry(): QtiInteractionElementSupport {
250
+ return {
251
+ ...entry("qti-text-entry-interaction", "textEntry"),
252
+ fixtures: [
253
+ "packages/fixtures/xml/textEntry-reference.xml",
254
+ "packages/fixtures/packages/sv-matrix/items/text-entry-pattern-mask-inline.xml",
255
+ ],
256
+ tests: [
257
+ "packages/fixtures/src/fixtures.test.ts",
258
+ "packages/conformance/src/conformance.test.ts",
259
+ "packages/a11y/src/a11y.test.ts",
260
+ "packages/core/src/pattern-mask.test.ts",
261
+ "tests/browser/player.spec.ts",
262
+ "tests/browser/player-dom-behavior.spec.ts",
263
+ ],
264
+ notes: "Supports placeholder-text and pattern-mask on text-entry controls.",
265
+ };
266
+ }
267
+
228
268
  function pciEntry(): QtiInteractionElementSupport {
229
269
  return {
230
270
  ...entry("qti-portable-custom-interaction", "portableCustom"),
package/src/types.ts CHANGED
@@ -138,7 +138,10 @@ export interface QtiTemplateDeclaration extends QtiVariableDeclaration {
138
138
 
139
139
  export interface QtiChoice {
140
140
  identifier: string;
141
+ /** Plain-text fallback used for speech, TTS, diagnostics, and native controls. */
141
142
  text: string;
143
+ /** Parsed visual content for renderers that can display rich choice markup such as MathML. */
144
+ content?: QtiContentNode[] | undefined;
142
145
  asset?: QtiObjectAsset | undefined;
143
146
  role: QtiChoiceRole;
144
147
  qtiName: string;
@@ -192,7 +195,10 @@ export interface QtiInteraction {
192
195
  responseIdentifier?: string | undefined;
193
196
  responseCardinality?: QtiCardinality | undefined;
194
197
  responseBaseType?: QtiBaseType | undefined;
198
+ /** Flat accessibility/fallback label derived from prompt content with annotations stripped. */
195
199
  prompt?: string | undefined;
200
+ /** Structured prompt content for visual rendering. When present, block headings render this instead of prompt. */
201
+ promptContent?: QtiContentNode[] | undefined;
196
202
  promptAttributes?: Record<string, string> | undefined;
197
203
  promptSource?: QtiSourceLocation | undefined;
198
204
  contextText?: string | undefined;
package/src/validation.ts CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  validateSharedVocabularyInputWidth,
27
27
  validateSharedVocabularyMediaPlayerControls,
28
28
  } from "./shared-vocabulary-validation.js";
29
+ import { isValidQtiPatternMask } from "./pattern-mask.js";
29
30
  import { validateQtiDataSsmlMetadata } from "./tts.js";
30
31
  import { qtiValueToStringList } from "./value-format.js";
31
32
 
@@ -1672,6 +1673,7 @@ function validateInteractions(item: QtiAssessmentItem, diagnostics: QtiDiagnosti
1672
1673
  validateInteractionRequiredAttributes(interaction, diagnostics);
1673
1674
  validatePortableCustomInteraction(interaction, item, diagnostics);
1674
1675
  validateInteractionLimitAttributes(interaction, diagnostics);
1676
+ validatePatternMaskAttribute(interaction, diagnostics);
1675
1677
  validateGraphicHotspotObjectDimensions(interaction, diagnostics);
1676
1678
  validateCorrectResponseReferences(
1677
1679
  interaction,
@@ -1724,6 +1726,7 @@ function validateInteractionSharedVocabulary(
1724
1726
  ...validateSharedVocabularyExtendedText({
1725
1727
  classNames,
1726
1728
  subjectQtiName: interaction.qtiName,
1729
+ expectedLength: interaction.attributes["expected-length"],
1727
1730
  path: interaction.source?.path,
1728
1731
  source: interaction.source,
1729
1732
  }),
@@ -2526,6 +2529,22 @@ function invalidNumber(
2526
2529
  });
2527
2530
  }
2528
2531
 
2532
+ function validatePatternMaskAttribute(
2533
+ interaction: QtiInteraction,
2534
+ diagnostics: QtiDiagnostic[],
2535
+ ): void {
2536
+ if (interaction.type !== "textEntry" && interaction.type !== "extendedText") return;
2537
+ const patternMask = interaction.attributes["pattern-mask"];
2538
+ if (patternMask === undefined || isValidQtiPatternMask(patternMask)) return;
2539
+ diagnostics.push({
2540
+ code: "interaction.patternMask.invalid",
2541
+ severity: "error",
2542
+ message: `${interaction.qtiName} pattern-mask is not a valid regular expression.`,
2543
+ path: interaction.source?.path,
2544
+ source: interaction.source,
2545
+ });
2546
+ }
2547
+
2529
2548
  function validateInteractionLimitAttributes(
2530
2549
  interaction: QtiInteraction,
2531
2550
  diagnostics: QtiDiagnostic[],
package/src/xml.ts CHANGED
@@ -142,6 +142,12 @@ const inlineMixedContentChildNames = new Set([
142
142
  "i",
143
143
  "kbd",
144
144
  "mark",
145
+ "math",
146
+ "mi",
147
+ "mn",
148
+ "mo",
149
+ "mrow",
150
+ "msup",
145
151
  "q",
146
152
  "rp",
147
153
  "rt",
@@ -174,6 +180,8 @@ function restoreMixedContentFromSource(xml: string, node: XmlNode): void {
174
180
  if (typeof entry !== "string") restoreMixedContentFromSource(xml, entry);
175
181
  }
176
182
 
183
+ restoreLeafTextFromSource(xml, node);
184
+
177
185
  if (!shouldRestoreMixedContentWhitespace(node)) return;
178
186
 
179
187
  const contentEndOffset = node.endSource?.offset ?? node.sourceRange.endOffset;
@@ -203,11 +211,26 @@ function restoreMixedContentFromSource(xml: string, node: XmlNode): void {
203
211
  node.text = restored.filter((entry): entry is string => typeof entry === "string").join("");
204
212
  }
205
213
 
214
+ function restoreLeafTextFromSource(xml: string, node: XmlNode): void {
215
+ if (node.children.length > 0) return;
216
+ const contentEndOffset = node.endSource?.offset ?? node.sourceRange.endOffset;
217
+ if (node.sourceRange.startTagEndOffset < 0 || contentEndOffset === undefined) return;
218
+ const raw = xml.slice(node.sourceRange.startTagEndOffset + 1, contentEndOffset);
219
+ if (raw.includes("<![CDATA[")) return;
220
+ const decoded = decodeXmlCharacterData(stripNonCharacterMarkup(raw));
221
+ node.text = decoded;
222
+ node.content = decoded.length > 0 ? [decoded] : [];
223
+ }
224
+
206
225
  function appendDecodedTextSegment(content: Array<string | XmlNode>, raw: string): void {
207
- const decoded = decodeXmlCharacterData(raw);
226
+ const decoded = decodeXmlCharacterData(stripNonCharacterMarkup(raw));
208
227
  if (decoded.length > 0) content.push(decoded);
209
228
  }
210
229
 
230
+ function stripNonCharacterMarkup(value: string): string {
231
+ return value.replace(/<!--[\s\S]*?-->/g, "").replace(/<\?[\s\S]*?\?>/g, "");
232
+ }
233
+
211
234
  const predefinedXmlEntities: Record<string, string> = {
212
235
  amp: "&",
213
236
  apos: "'",