@longsightgroup/qti3-core 0.6.0 → 0.7.1

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 (50) hide show
  1. package/dist/index.d.ts +6 -1
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +5 -0
  4. package/dist/index.js.map +1 -1
  5. package/dist/parser.js +13 -1
  6. package/dist/parser.js.map +1 -1
  7. package/dist/shared-vocabulary-coverage-policy.d.ts +10 -0
  8. package/dist/shared-vocabulary-coverage-policy.d.ts.map +1 -0
  9. package/dist/shared-vocabulary-coverage-policy.js +61 -0
  10. package/dist/shared-vocabulary-coverage-policy.js.map +1 -0
  11. package/dist/shared-vocabulary-generated-families.d.ts +21 -0
  12. package/dist/shared-vocabulary-generated-families.d.ts.map +1 -0
  13. package/dist/shared-vocabulary-generated-families.js +194 -0
  14. package/dist/shared-vocabulary-generated-families.js.map +1 -0
  15. package/dist/shared-vocabulary-levels.d.ts +4 -0
  16. package/dist/shared-vocabulary-levels.d.ts.map +1 -0
  17. package/dist/shared-vocabulary-levels.js +4 -0
  18. package/dist/shared-vocabulary-levels.js.map +1 -0
  19. package/dist/shared-vocabulary-support.d.ts +3 -0
  20. package/dist/shared-vocabulary-support.d.ts.map +1 -0
  21. package/dist/shared-vocabulary-support.js +208 -0
  22. package/dist/shared-vocabulary-support.js.map +1 -0
  23. package/dist/shared-vocabulary-validation.d.ts +40 -0
  24. package/dist/shared-vocabulary-validation.d.ts.map +1 -0
  25. package/dist/shared-vocabulary-validation.js +108 -0
  26. package/dist/shared-vocabulary-validation.js.map +1 -0
  27. package/dist/shared-vocabulary.d.ts +35 -0
  28. package/dist/shared-vocabulary.d.ts.map +1 -0
  29. package/dist/shared-vocabulary.js +128 -0
  30. package/dist/shared-vocabulary.js.map +1 -0
  31. package/dist/types.d.ts +10 -0
  32. package/dist/types.d.ts.map +1 -1
  33. package/dist/validation.d.ts.map +1 -1
  34. package/dist/validation.js +364 -0
  35. package/dist/validation.js.map +1 -1
  36. package/dist/xml.d.ts.map +1 -1
  37. package/dist/xml.js +100 -0
  38. package/dist/xml.js.map +1 -1
  39. package/package.json +1 -1
  40. package/src/index.ts +56 -0
  41. package/src/parser.ts +17 -1
  42. package/src/shared-vocabulary-coverage-policy.ts +79 -0
  43. package/src/shared-vocabulary-generated-families.ts +252 -0
  44. package/src/shared-vocabulary-levels.ts +7 -0
  45. package/src/shared-vocabulary-support.ts +340 -0
  46. package/src/shared-vocabulary-validation.ts +182 -0
  47. package/src/shared-vocabulary.ts +177 -0
  48. package/src/types.ts +11 -0
  49. package/src/validation.ts +466 -0
  50. package/src/xml.ts +107 -0
package/src/types.ts CHANGED
@@ -139,6 +139,7 @@ export interface QtiTemplateDeclaration extends QtiVariableDeclaration {
139
139
  export interface QtiChoice {
140
140
  identifier: string;
141
141
  text: string;
142
+ asset?: QtiObjectAsset | undefined;
142
143
  role: QtiChoiceRole;
143
144
  qtiName: string;
144
145
  attributes: Record<string, string>;
@@ -704,3 +705,13 @@ export interface QtiInteractionElementSupport extends QtiElementSupportBase {
704
705
  export interface QtiProcessingElementSupport extends QtiElementSupportBase {
705
706
  category: "processing";
706
707
  }
708
+
709
+ export interface SharedVocabularyClassSupport {
710
+ className: string;
711
+ scope: "interaction" | "content" | "gap";
712
+ interactions?: QtiInteractionType[] | undefined;
713
+ level: "full" | "stylesheet" | "pass-through" | "conditional";
714
+ fixtures?: string[] | undefined;
715
+ tests?: string[] | undefined;
716
+ notes?: string | undefined;
717
+ }
package/src/validation.ts CHANGED
@@ -5,6 +5,7 @@ import type {
5
5
  QtiCatalogCardEntry,
6
6
  QtiCardinality,
7
7
  QtiChoice,
8
+ QtiElementContent,
8
9
  QtiContentNode,
9
10
  QtiDiagnostic,
10
11
  QtiDocument,
@@ -20,6 +21,11 @@ import type {
20
21
  QtiValue,
21
22
  QtiValidationResult,
22
23
  } from "./types.js";
24
+ import {
25
+ validateSharedVocabularyExtendedText,
26
+ validateSharedVocabularyInputWidth,
27
+ validateSharedVocabularyMediaPlayerControls,
28
+ } from "./shared-vocabulary-validation.js";
23
29
  import { validateQtiDataSsmlMetadata } from "./tts.js";
24
30
  import { qtiValueToStringList } from "./value-format.js";
25
31
 
@@ -32,6 +38,7 @@ export function validateAssessmentItem(document: QtiDocument): QtiValidationResu
32
38
  requireIdentifier("qti-assessment-item", item.identifier, diagnostics, item.source);
33
39
  validateAssessmentItemRoot(item, diagnostics);
34
40
  validateItemBody(item, diagnostics);
41
+ validateItemBodySharedVocabulary(item, diagnostics);
35
42
  validateDeclarationIdentifiers(item, diagnostics);
36
43
  validateOutcomeLookupTables(item, diagnostics);
37
44
  validateInteractions(item, diagnostics);
@@ -194,6 +201,73 @@ function validateItemBody(item: QtiAssessmentItem, diagnostics: QtiDiagnostic[])
194
201
  });
195
202
  }
196
203
 
204
+ function validateItemBodySharedVocabulary(
205
+ item: QtiAssessmentItem,
206
+ diagnostics: QtiDiagnostic[],
207
+ ): void {
208
+ validateContentSharedVocabulary(item.body, diagnostics);
209
+ }
210
+
211
+ function validateContentSharedVocabulary(
212
+ nodes: QtiContentNode[],
213
+ diagnostics: QtiDiagnostic[],
214
+ ): void {
215
+ for (const node of nodes) {
216
+ if (node.kind !== "element") continue;
217
+ validateLayoutVocabularyClasses(node, diagnostics);
218
+ if (sharedClassNames(node.attributes).includes("qti-layout-row")) {
219
+ validateLayoutRow(node, diagnostics);
220
+ }
221
+ validateContentSharedVocabulary(node.children, diagnostics);
222
+ }
223
+ }
224
+
225
+ function validateLayoutVocabularyClasses(
226
+ node: QtiElementContent,
227
+ diagnostics: QtiDiagnostic[],
228
+ ): void {
229
+ const classNames = sharedClassNames(node.attributes);
230
+ for (const className of classNames) {
231
+ if (isLayoutColumnClassName(className) && layoutColumnValue(className) === undefined) {
232
+ diagnostics.push({
233
+ code: "item.sharedVocabulary.layoutColumnInvalid",
234
+ severity: "warning",
235
+ message: `Shared vocabulary class ${className} is not supported; expected qti-layout-col1 through qti-layout-col12, or dashed qti-layout-col-1 through qti-layout-col-12.`,
236
+ path: node.source?.path,
237
+ source: node.source,
238
+ });
239
+ }
240
+ if (isLayoutOffsetClassName(className) && layoutOffsetValue(className) === undefined) {
241
+ diagnostics.push({
242
+ code: "item.sharedVocabulary.layoutOffsetInvalid",
243
+ severity: "warning",
244
+ message: `Shared vocabulary class ${className} is not supported; expected qti-layout-offset1 through qti-layout-offset11, or dashed qti-layout-offset-1 through qti-layout-offset-11.`,
245
+ path: node.source?.path,
246
+ source: node.source,
247
+ });
248
+ }
249
+ }
250
+ }
251
+
252
+ function validateLayoutRow(node: QtiElementContent, diagnostics: QtiDiagnostic[]): void {
253
+ let totalColumns = 0;
254
+ for (const child of node.children) {
255
+ if (child.kind !== "element") continue;
256
+ const classNames = sharedClassNames(child.attributes);
257
+ const column = firstLayoutColumnValue(classNames);
258
+ if (column === undefined) continue;
259
+ totalColumns += (firstLayoutOffsetValue(classNames) ?? 0) + column;
260
+ }
261
+ if (totalColumns <= 12) return;
262
+ diagnostics.push({
263
+ code: "item.sharedVocabulary.layoutRowOverflow",
264
+ severity: "warning",
265
+ message: `qti-layout-row column groupings plus offsets total ${totalColumns}; the QTI shared vocabulary grid allows at most twelve columns per row.`,
266
+ path: node.source?.path,
267
+ source: node.source,
268
+ });
269
+ }
270
+
197
271
  function requireIdentifier(
198
272
  elementName: string,
199
273
  identifier: string | undefined,
@@ -1592,6 +1666,7 @@ function validateInteractions(item: QtiAssessmentItem, diagnostics: QtiDiagnosti
1592
1666
  for (const interaction of item.interactions) {
1593
1667
  validateInteractionResponseReference(interaction, responseIdentifiers, diagnostics);
1594
1668
  validateInteractionResponseShape(interaction, diagnostics);
1669
+ validateInteractionSharedVocabulary(interaction, diagnostics);
1595
1670
  validateInteractionChoices(interaction, diagnostics);
1596
1671
  validateInteractionChildren(interaction, diagnostics);
1597
1672
  validateInteractionRequiredAttributes(interaction, diagnostics);
@@ -1615,6 +1690,385 @@ function validateInteractions(item: QtiAssessmentItem, diagnostics: QtiDiagnosti
1615
1690
  }
1616
1691
  }
1617
1692
 
1693
+ function validateInteractionSharedVocabulary(
1694
+ interaction: QtiInteraction,
1695
+ diagnostics: QtiDiagnostic[],
1696
+ ): void {
1697
+ if (
1698
+ interaction.type !== "choice" &&
1699
+ interaction.type !== "match" &&
1700
+ interaction.type !== "gapMatch" &&
1701
+ interaction.type !== "graphicGapMatch" &&
1702
+ interaction.type !== "inlineChoice" &&
1703
+ interaction.type !== "textEntry" &&
1704
+ interaction.type !== "extendedText" &&
1705
+ interaction.type !== "media" &&
1706
+ interaction.type !== "order"
1707
+ ) {
1708
+ return;
1709
+ }
1710
+ const classNames = sharedClassNames(interaction.attributes);
1711
+ const classNameSet = new Set(classNames);
1712
+ if (interaction.type === "media") {
1713
+ validateMediaInteractionSharedVocabulary(interaction, diagnostics);
1714
+ return;
1715
+ }
1716
+
1717
+ if (interaction.type === "inlineChoice" || interaction.type === "textEntry") {
1718
+ validateInteractionInputWidthSharedVocabulary(interaction, classNames, diagnostics);
1719
+ return;
1720
+ }
1721
+
1722
+ if (interaction.type === "extendedText") {
1723
+ diagnostics.push(
1724
+ ...validateSharedVocabularyExtendedText({
1725
+ classNames,
1726
+ subjectQtiName: interaction.qtiName,
1727
+ path: interaction.source?.path,
1728
+ source: interaction.source,
1729
+ }),
1730
+ );
1731
+ return;
1732
+ }
1733
+
1734
+ if (interaction.type === "choice" || interaction.type === "order") {
1735
+ validateSharedVocabularyLabelClasses(interaction, classNames, diagnostics);
1736
+ }
1737
+
1738
+ if (
1739
+ interaction.type === "match" ||
1740
+ interaction.type === "gapMatch" ||
1741
+ interaction.type === "graphicGapMatch"
1742
+ ) {
1743
+ if (interaction.type === "match") {
1744
+ validateMatchInteractionSharedVocabulary(interaction, classNames, diagnostics);
1745
+ }
1746
+ validateChoicesPositionSharedVocabulary(interaction, classNames, diagnostics);
1747
+ validateChoicesContainerWidthSharedVocabulary(interaction, diagnostics);
1748
+ if (interaction.type === "gapMatch") {
1749
+ validateGapInputWidthSharedVocabulary(interaction, diagnostics);
1750
+ }
1751
+ return;
1752
+ }
1753
+
1754
+ if (interaction.type === "order") {
1755
+ validateOrderInteractionSharedVocabulary(interaction, classNames, classNameSet, diagnostics);
1756
+ return;
1757
+ }
1758
+
1759
+ validateOrientationSharedVocabulary(interaction, classNameSet, diagnostics);
1760
+
1761
+ const validStackingClasses = new Set<string>();
1762
+ const invalidStackingClasses = new Set<string>();
1763
+ for (const className of classNames) {
1764
+ const stacking = /^qti-choices-stacking-(\d+)$/.exec(className)?.[1];
1765
+ if (stacking === undefined) continue;
1766
+ const count = Number(stacking);
1767
+ if (count >= 1 && count <= 5) validStackingClasses.add(className);
1768
+ else invalidStackingClasses.add(className);
1769
+ }
1770
+
1771
+ if (validStackingClasses.size > 1) {
1772
+ diagnostics.push({
1773
+ code: "interaction.sharedVocabulary.stackingConflict",
1774
+ severity: "warning",
1775
+ message: `qti-choice-interaction should not include multiple qti-choices-stacking-* classes: ${[...validStackingClasses].join(", ")}. The first valid stacking class in class attribute order takes precedence at runtime.`,
1776
+ path: interaction.source?.path,
1777
+ source: interaction.source,
1778
+ });
1779
+ }
1780
+
1781
+ for (const className of invalidStackingClasses) {
1782
+ diagnostics.push({
1783
+ code: "interaction.sharedVocabulary.stackingInvalid",
1784
+ severity: "warning",
1785
+ message: `qti-choice-interaction shared vocabulary class ${className} is not supported; expected qti-choices-stacking-1 through qti-choices-stacking-5.`,
1786
+ path: interaction.source?.path,
1787
+ source: interaction.source,
1788
+ });
1789
+ }
1790
+ }
1791
+
1792
+ function validateSharedVocabularyLabelClasses(
1793
+ interaction: QtiInteraction,
1794
+ classNames: string[],
1795
+ diagnostics: QtiDiagnostic[],
1796
+ ): void {
1797
+ const labelClasses = classNames.filter((className) =>
1798
+ [
1799
+ "qti-labels-decimal",
1800
+ "qti-labels-cjk-ideographic",
1801
+ "qti-labels-lower-alpha",
1802
+ "qti-labels-upper-alpha",
1803
+ ].includes(className),
1804
+ );
1805
+ if (new Set(labelClasses).size > 1) {
1806
+ diagnostics.push({
1807
+ code: "interaction.sharedVocabulary.labelsConflict",
1808
+ severity: "warning",
1809
+ message: `${interaction.qtiName} should not include multiple qti-labels-* style classes: ${[...new Set(labelClasses)].join(", ")}. qti-labels-decimal takes precedence over qti-labels-cjk-ideographic, then qti-labels-lower-alpha, then qti-labels-upper-alpha at runtime.`,
1810
+ path: interaction.source?.path,
1811
+ source: interaction.source,
1812
+ });
1813
+ }
1814
+
1815
+ const suffixClasses = classNames.filter((className) =>
1816
+ [
1817
+ "qti-labels-suffix-none",
1818
+ "qti-labels-suffix-period",
1819
+ "qti-labels-suffix-parenthesis",
1820
+ ].includes(className),
1821
+ );
1822
+ if (new Set(suffixClasses).size <= 1) return;
1823
+ diagnostics.push({
1824
+ code: "interaction.sharedVocabulary.labelSuffixConflict",
1825
+ severity: "warning",
1826
+ message: `${interaction.qtiName} should not include multiple qti-labels-suffix-* classes: ${[...new Set(suffixClasses)].join(", ")}. qti-labels-suffix-none takes precedence over qti-labels-suffix-period, then qti-labels-suffix-parenthesis at runtime.`,
1827
+ path: interaction.source?.path,
1828
+ source: interaction.source,
1829
+ });
1830
+ }
1831
+
1832
+ function validateOrientationSharedVocabulary(
1833
+ interaction: QtiInteraction,
1834
+ classNameSet: Set<string>,
1835
+ diagnostics: QtiDiagnostic[],
1836
+ ): void {
1837
+ if (
1838
+ !classNameSet.has("qti-orientation-horizontal") ||
1839
+ !classNameSet.has("qti-orientation-vertical")
1840
+ ) {
1841
+ return;
1842
+ }
1843
+ diagnostics.push({
1844
+ code: "interaction.sharedVocabulary.orientationConflict",
1845
+ severity: "warning",
1846
+ message: `${interaction.qtiName} should not include both qti-orientation-horizontal and qti-orientation-vertical; qti-orientation-horizontal takes precedence at runtime.`,
1847
+ path: interaction.source?.path,
1848
+ source: interaction.source,
1849
+ });
1850
+ }
1851
+
1852
+ function validateOrderInteractionSharedVocabulary(
1853
+ interaction: QtiInteraction,
1854
+ classNames: string[],
1855
+ classNameSet: Set<string>,
1856
+ diagnostics: QtiDiagnostic[],
1857
+ ): void {
1858
+ validateOrientationSharedVocabulary(interaction, classNameSet, diagnostics);
1859
+ validateChoicesPositionSharedVocabulary(interaction, classNames, diagnostics);
1860
+ validateChoicesContainerWidthSharedVocabulary(interaction, diagnostics);
1861
+ }
1862
+
1863
+ const SHARED_VOCABULARY_CHOICES_POSITION_CLASSES = [
1864
+ "qti-choices-top",
1865
+ "qti-choices-bottom",
1866
+ "qti-choices-left",
1867
+ "qti-choices-right",
1868
+ ] as const;
1869
+
1870
+ function validateMatchInteractionSharedVocabulary(
1871
+ interaction: QtiInteraction,
1872
+ classNames: string[],
1873
+ diagnostics: QtiDiagnostic[],
1874
+ ): void {
1875
+ const hasTabular = classNames.includes("qti-match-tabular");
1876
+ const hasHeaderHidden = classNames.includes("qti-header-hidden");
1877
+ const firstColumnHeader = interaction.attributes["data-first-column-header"];
1878
+ if (!hasTabular && (hasHeaderHidden || firstColumnHeader !== undefined)) {
1879
+ diagnostics.push({
1880
+ code: "interaction.sharedVocabulary.matchTabularContext",
1881
+ severity: "warning",
1882
+ message:
1883
+ "qti-match-interaction shared vocabulary class qti-header-hidden and data-first-column-header are only relevant when qti-match-tabular is specified; they are ignored at runtime.",
1884
+ path: interaction.source?.path,
1885
+ source: interaction.source,
1886
+ });
1887
+ }
1888
+ if (hasTabular && hasHeaderHidden && firstColumnHeader !== undefined) {
1889
+ diagnostics.push({
1890
+ code: "interaction.sharedVocabulary.matchTabularHeaderHidden",
1891
+ severity: "warning",
1892
+ message:
1893
+ "qti-match-interaction data-first-column-header is ignored when qti-header-hidden suppresses the tabular header row.",
1894
+ path: interaction.source?.path,
1895
+ source: interaction.source,
1896
+ });
1897
+ }
1898
+ if (!hasTabular) return;
1899
+
1900
+ const choicesPositionClasses = classNames.filter((className) =>
1901
+ SHARED_VOCABULARY_CHOICES_POSITION_CLASSES.includes(
1902
+ className as (typeof SHARED_VOCABULARY_CHOICES_POSITION_CLASSES)[number],
1903
+ ),
1904
+ );
1905
+ if (
1906
+ choicesPositionClasses.length > 0 ||
1907
+ interaction.attributes["data-choices-container-width"] !== undefined
1908
+ ) {
1909
+ diagnostics.push({
1910
+ code: "interaction.sharedVocabulary.matchTabularChoicesConflict",
1911
+ severity: "warning",
1912
+ message:
1913
+ "qti-match-interaction qti-match-tabular uses a matrix layout; qti-choices-* position classes and data-choices-container-width are ignored at runtime.",
1914
+ path: interaction.source?.path,
1915
+ source: interaction.source,
1916
+ });
1917
+ }
1918
+ if (!hasHeaderHidden && (firstColumnHeader === undefined || firstColumnHeader === "")) {
1919
+ diagnostics.push({
1920
+ code: "interaction.sharedVocabulary.matchTabularFirstColumnHeader",
1921
+ severity: "warning",
1922
+ message:
1923
+ "qti-match-interaction with qti-match-tabular should specify data-first-column-header for the top-left table header when the tabular header row is shown.",
1924
+ path: interaction.source?.path,
1925
+ source: interaction.source,
1926
+ });
1927
+ }
1928
+ }
1929
+
1930
+ function validateChoicesPositionSharedVocabulary(
1931
+ interaction: QtiInteraction,
1932
+ classNames: string[],
1933
+ diagnostics: QtiDiagnostic[],
1934
+ ): void {
1935
+ const choicesPositionClasses = classNames.filter((className) =>
1936
+ SHARED_VOCABULARY_CHOICES_POSITION_CLASSES.includes(
1937
+ className as (typeof SHARED_VOCABULARY_CHOICES_POSITION_CLASSES)[number],
1938
+ ),
1939
+ );
1940
+ if (new Set(choicesPositionClasses).size > 1) {
1941
+ diagnostics.push({
1942
+ code: "interaction.sharedVocabulary.orderChoicesPositionConflict",
1943
+ severity: "warning",
1944
+ message: `${interaction.qtiName} should not include multiple qti-choices-* position classes: ${[...new Set(choicesPositionClasses)].join(", ")}. The first position class in class attribute order takes precedence at runtime.`,
1945
+ path: interaction.source?.path,
1946
+ source: interaction.source,
1947
+ });
1948
+ }
1949
+ }
1950
+
1951
+ function validateChoicesContainerWidthSharedVocabulary(
1952
+ interaction: QtiInteraction,
1953
+ diagnostics: QtiDiagnostic[],
1954
+ ): void {
1955
+ const width = interaction.attributes["data-choices-container-width"];
1956
+ if (width === undefined) return;
1957
+ const parsed = Number(width);
1958
+ if (!Number.isFinite(parsed) || parsed <= 0) {
1959
+ diagnostics.push({
1960
+ code: "interaction.sharedVocabulary.orderChoicesContainerWidth",
1961
+ severity: "warning",
1962
+ message: `${interaction.qtiName} data-choices-container-width must be a positive pixel value; the invalid value is ignored at runtime.`,
1963
+ path: interaction.source?.path,
1964
+ source: interaction.source,
1965
+ });
1966
+ }
1967
+ }
1968
+
1969
+ function validateGapInputWidthSharedVocabulary(
1970
+ interaction: QtiInteraction,
1971
+ diagnostics: QtiDiagnostic[],
1972
+ ): void {
1973
+ for (const gap of interaction.choices.filter((choice) => choice.qtiName === "qti-gap")) {
1974
+ diagnostics.push(
1975
+ ...validateSharedVocabularyInputWidth({
1976
+ classNames: sharedClassNames(gap.attributes),
1977
+ subjectQtiName: "qti-gap",
1978
+ path: gap.source?.path ?? interaction.source?.path,
1979
+ source: gap.source ?? interaction.source,
1980
+ conflictCode: "interaction.sharedVocabulary.gapInputWidthConflict",
1981
+ invalidCode: "interaction.sharedVocabulary.gapInputWidthInvalid",
1982
+ }),
1983
+ );
1984
+ }
1985
+ }
1986
+
1987
+ function validateInteractionInputWidthSharedVocabulary(
1988
+ interaction: QtiInteraction,
1989
+ classNames: string[],
1990
+ diagnostics: QtiDiagnostic[],
1991
+ ): void {
1992
+ diagnostics.push(
1993
+ ...validateSharedVocabularyInputWidth({
1994
+ classNames,
1995
+ subjectQtiName: interaction.qtiName,
1996
+ path: interaction.source?.path,
1997
+ source: interaction.source,
1998
+ conflictCode: "interaction.sharedVocabulary.inputWidthConflict",
1999
+ invalidCode: "interaction.sharedVocabulary.inputWidthInvalid",
2000
+ }),
2001
+ );
2002
+ }
2003
+
2004
+ function validateMediaInteractionSharedVocabulary(
2005
+ interaction: QtiInteraction,
2006
+ diagnostics: QtiDiagnostic[],
2007
+ ): void {
2008
+ diagnostics.push(
2009
+ ...validateSharedVocabularyMediaPlayerControls({
2010
+ value: interaction.attributes["data-qti-media-player-controls"],
2011
+ subjectQtiName: interaction.qtiName,
2012
+ path: interaction.source?.path,
2013
+ source: interaction.source,
2014
+ }),
2015
+ );
2016
+
2017
+ if (!interaction.object) return;
2018
+ diagnostics.push(
2019
+ ...validateSharedVocabularyMediaPlayerControls({
2020
+ value: interaction.object.attributes["data-qti-media-player-controls"],
2021
+ subjectQtiName: `${interaction.qtiName} media object`,
2022
+ path: interaction.object.source?.path ?? interaction.source?.path,
2023
+ source: interaction.object.source ?? interaction.source,
2024
+ }),
2025
+ );
2026
+ }
2027
+
2028
+ function sharedClassNames(attributes: Record<string, string>): string[] {
2029
+ return (attributes.class ?? "").split(/\s+/).filter(Boolean);
2030
+ }
2031
+
2032
+ function isLayoutColumnClassName(className: string): boolean {
2033
+ return /^qti-layout-col-?\w+$/.test(className);
2034
+ }
2035
+
2036
+ function layoutColumnValue(className: string): number | undefined {
2037
+ const rawValue = /^qti-layout-col-?(\d+)$/.exec(className)?.[1];
2038
+ if (rawValue === undefined) return undefined;
2039
+ const value = Number(rawValue);
2040
+ if (value < 1 || value > 12) return undefined;
2041
+ return value;
2042
+ }
2043
+
2044
+ function firstLayoutColumnValue(classNames: string[]): number | undefined {
2045
+ for (const className of classNames) {
2046
+ const value = layoutColumnValue(className);
2047
+ if (value !== undefined) return value;
2048
+ }
2049
+ return undefined;
2050
+ }
2051
+
2052
+ function isLayoutOffsetClassName(className: string): boolean {
2053
+ return /^qti-layout-offset-?\w+$/.test(className);
2054
+ }
2055
+
2056
+ function layoutOffsetValue(className: string): number | undefined {
2057
+ const rawValue = /^qti-layout-offset-?(\d+)$/.exec(className)?.[1];
2058
+ if (rawValue === undefined) return undefined;
2059
+ const value = Number(rawValue);
2060
+ if (value < 1 || value > 11) return undefined;
2061
+ return value;
2062
+ }
2063
+
2064
+ function firstLayoutOffsetValue(classNames: string[]): number | undefined {
2065
+ for (const className of classNames) {
2066
+ const value = layoutOffsetValue(className);
2067
+ if (value !== undefined) return value;
2068
+ }
2069
+ return undefined;
2070
+ }
2071
+
1618
2072
  function validateInteractionResponseReference(
1619
2073
  interaction: QtiInteraction,
1620
2074
  responseIdentifiers: Set<string>,
@@ -2108,9 +2562,21 @@ function validateChoiceLimitAttributes(choice: QtiChoice, diagnostics: QtiDiagno
2108
2562
  validateChoiceNonNegativeIntegerAttribute(choice, "match-max", diagnostics);
2109
2563
  validateChoiceNonNegativeIntegerAttribute(choice, "match-min", diagnostics);
2110
2564
  validateChoiceMinMaxPair(choice, "match-min", "match-max", diagnostics);
2565
+ validateGapImageAsset(choice, diagnostics);
2111
2566
  validateHotspotGeometry(choice, diagnostics);
2112
2567
  }
2113
2568
 
2569
+ function validateGapImageAsset(choice: QtiChoice, diagnostics: QtiDiagnostic[]): void {
2570
+ if (choice.qtiName !== "qti-gap-img" || choice.asset?.data) return;
2571
+ diagnostics.push({
2572
+ code: "choice.gapImg.media.required",
2573
+ severity: "error",
2574
+ message: `qti-gap-img ${choice.identifier} requires an img, object, or picture child with a usable src or data attribute.`,
2575
+ path: choice.source?.path,
2576
+ source: choice.source,
2577
+ });
2578
+ }
2579
+
2114
2580
  function requiresMatchMax(choice: QtiChoice): boolean {
2115
2581
  return (
2116
2582
  choice.qtiName === "qti-simple-associable-choice" ||
package/src/xml.ts CHANGED
@@ -120,9 +120,116 @@ export function parseXmlTree(xml: string): { root: XmlNode | undefined; errors:
120
120
  errors.push(new Error(`Unexpected end of document. Missing closing tag for <${node.name}>.`));
121
121
  }
122
122
 
123
+ if (root) restoreMixedContentFromSource(xml, root);
124
+
123
125
  return { root, errors };
124
126
  }
125
127
 
128
+ /**
129
+ * stax-xml trims boundary whitespace around child elements. Re-slice mixed content from the
130
+ * original XML so authored spacing around inline markup (for example `<em>`) is preserved.
131
+ */
132
+ const inlineMixedContentChildNames = new Set([
133
+ "a",
134
+ "abbr",
135
+ "b",
136
+ "bdi",
137
+ "bdo",
138
+ "cite",
139
+ "code",
140
+ "dfn",
141
+ "em",
142
+ "i",
143
+ "kbd",
144
+ "mark",
145
+ "q",
146
+ "rp",
147
+ "rt",
148
+ "ruby",
149
+ "s",
150
+ "samp",
151
+ "small",
152
+ "span",
153
+ "strong",
154
+ "sub",
155
+ "sup",
156
+ "var",
157
+ "qti-feedback-inline",
158
+ "qti-gap",
159
+ "qti-hottext",
160
+ "qti-inline-choice-interaction",
161
+ "qti-printed-variable",
162
+ "qti-template-inline",
163
+ "qti-text-entry-interaction",
164
+ ]);
165
+
166
+ function shouldRestoreMixedContentWhitespace(node: XmlNode): boolean {
167
+ return node.content.some(
168
+ (entry) => typeof entry !== "string" && inlineMixedContentChildNames.has(entry.localName),
169
+ );
170
+ }
171
+
172
+ function restoreMixedContentFromSource(xml: string, node: XmlNode): void {
173
+ for (const entry of node.content) {
174
+ if (typeof entry !== "string") restoreMixedContentFromSource(xml, entry);
175
+ }
176
+
177
+ if (!shouldRestoreMixedContentWhitespace(node)) return;
178
+
179
+ const contentEndOffset = node.endSource?.offset ?? node.sourceRange.endOffset;
180
+ if (node.sourceRange.startTagEndOffset < 0 || contentEndOffset === undefined) return;
181
+
182
+ const restored: Array<string | XmlNode> = [];
183
+ let cursor = node.sourceRange.startTagEndOffset + 1;
184
+
185
+ for (const entry of node.content) {
186
+ if (typeof entry === "string") continue;
187
+ const childStart = entry.sourceRange.startOffset;
188
+ if (childStart < 0) continue;
189
+ if (childStart > cursor) {
190
+ appendDecodedTextSegment(restored, xml.slice(cursor, childStart));
191
+ }
192
+ restored.push(entry);
193
+ const childEnd = entry.sourceRange.endOffset;
194
+ if (childEnd === undefined || childEnd < cursor) continue;
195
+ cursor = childEnd;
196
+ }
197
+
198
+ if (contentEndOffset > cursor) {
199
+ appendDecodedTextSegment(restored, xml.slice(cursor, contentEndOffset));
200
+ }
201
+
202
+ node.content = restored;
203
+ node.text = restored.filter((entry): entry is string => typeof entry === "string").join("");
204
+ }
205
+
206
+ function appendDecodedTextSegment(content: Array<string | XmlNode>, raw: string): void {
207
+ const decoded = decodeXmlCharacterData(raw);
208
+ if (decoded.length > 0) content.push(decoded);
209
+ }
210
+
211
+ const predefinedXmlEntities: Record<string, string> = {
212
+ amp: "&",
213
+ apos: "'",
214
+ gt: ">",
215
+ lt: "<",
216
+ quot: '"',
217
+ };
218
+
219
+ function decodeXmlCharacterData(value: string): string {
220
+ return value.replace(/&(#x?[0-9a-fA-F]+|[A-Za-z]+);/g, (entity, body: string) => {
221
+ if (body.startsWith("#x") || body.startsWith("#X")) {
222
+ const codePoint = Number.parseInt(body.slice(2), 16);
223
+ return Number.isFinite(codePoint) ? String.fromCodePoint(codePoint) : entity;
224
+ }
225
+ if (body.startsWith("#")) {
226
+ const codePoint = Number.parseInt(body.slice(1), 10);
227
+ return Number.isFinite(codePoint) ? String.fromCodePoint(codePoint) : entity;
228
+ }
229
+ return predefinedXmlEntities[body] ?? entity;
230
+ });
231
+ }
232
+
126
233
  export function childElements(node: XmlNode, localName?: string): XmlNode[] {
127
234
  return node.children.filter((child) => !localName || child.localName === localName);
128
235
  }