@longsightgroup/qti3-core 0.1.2 → 0.2.0

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