Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
60.93% |
92 / 151 |
|
27.27% |
3 / 11 |
CRAP | |
0.00% |
0 / 1 |
AbstractQtiConverter | |
60.93% |
92 / 151 |
|
27.27% |
3 / 11 |
299.76 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
convertToQti2 | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
3 | |||
convertRootElementsRecursively | |
61.76% |
21 / 34 |
|
0.00% |
0 / 1 |
27.58 | |||
adjustAttributes | |
77.78% |
7 / 9 |
|
0.00% |
0 / 1 |
5.27 | |||
convertRootElement | |
96.97% |
32 / 33 |
|
0.00% |
0 / 1 |
2 | |||
getRootElement | n/a |
0 / 0 |
n/a |
0 / 0 |
0 | |||||
isTagValid | |
41.67% |
10 / 24 |
|
0.00% |
0 / 1 |
20.70 | |||
canBeConverted | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
4 | |||
convertAttributesToQTI2 | |
21.21% |
7 / 33 |
|
0.00% |
0 / 1 |
195.56 | |||
isExpectedTextLengthEquivalent | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
6 | |||
isExpectedTextLinesEquivalent | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
6 | |||
isExpectedChoiceListStyleEquivalent | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
6 |
1 | <?php |
2 | |
3 | /** |
4 | * This program is free software; you can redistribute it and/or |
5 | * modify it under the terms of the GNU General Public License |
6 | * as published by the Free Software Foundation; under version 2 |
7 | * of the License (non-upgradable). |
8 | * |
9 | * This program is distributed in the hope that it will be useful, |
10 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
12 | * GNU General Public License for more details. |
13 | * |
14 | * You should have received a copy of the GNU General Public License |
15 | * along with this program; if not, write to the Free Software |
16 | * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
17 | * |
18 | * Copyright (c) 2024-2025 (original work) Open Assessment Technologies SA; |
19 | */ |
20 | |
21 | declare(strict_types=1); |
22 | |
23 | namespace oat\taoQtiItem\model\qti\converter; |
24 | |
25 | use common_Logger; |
26 | use DOMAttr; |
27 | use DOMComment; |
28 | use DOMDocument; |
29 | use DOMElement; |
30 | use DOMText; |
31 | use DOMXPath; |
32 | use oat\taoQtiItem\model\ValidationService; |
33 | |
34 | abstract class AbstractQtiConverter |
35 | { |
36 | private const QTI_22_NS = 'http://www.imsglobal.org/xsd/imsqti_v2p2'; |
37 | private const QTI_3_NS = 'http://www.imsglobal.org/xsd/imsqtiasi_v3p0'; |
38 | private const XSI_NAMESPACE = 'http://www.w3.org/2001/XMLSchema-instance'; |
39 | private const XSI_MATH_NAMESPACE = 'http://www.w3.org/1998/Math/MathML'; |
40 | private const XSI_SCHEMA_LOCATION = 'xsi:schemaLocation'; |
41 | private const SCHEMA_LOCATION = 'http://www.imsglobal.org/xsd/imsqti_v2p2 ' . |
42 | 'http://www.imsglobal.org/xsd/qti/qtiv2p2/imsqti_v2p2p1.xsd'; |
43 | private const XML_NS_NAMESPACE = 'http://www.w3.org/2000/xmlns/'; |
44 | private const QUALIFIED_NAME_NS = 'xmlns'; |
45 | private const QUALIFIED_NAME_XSI = self::QUALIFIED_NAME_NS . ':xsi'; |
46 | private const QUALIFIED_NAME_MATH = self::QUALIFIED_NAME_NS . ':m'; |
47 | |
48 | private CaseConversionService $caseConversionService; |
49 | private ValidationService $validationService; |
50 | |
51 | public function __construct(CaseConversionService $caseConversionService, ValidationService $validationService) |
52 | { |
53 | $this->caseConversionService = $caseConversionService; |
54 | $this->validationService = $validationService; |
55 | } |
56 | |
57 | public function convertToQti2(string $filename): void |
58 | { |
59 | // Load the QTI XML document |
60 | $dom = new DOMDocument(); |
61 | $dom->preserveWhiteSpace = false; |
62 | $dom->formatOutput = true; |
63 | $dom->load($filename); |
64 | foreach (iterator_to_array($dom->childNodes) as $child) { |
65 | if ($this->canBeConverted($child)) { |
66 | $this->convertRootElementsRecursively(iterator_to_array($child->childNodes)); |
67 | $this->convertRootElement($child); |
68 | $dom->save($filename); |
69 | } |
70 | } |
71 | } |
72 | |
73 | private function convertRootElementsRecursively(array $children, array $params = []): void |
74 | { |
75 | foreach ($children as $child) { |
76 | if ($child instanceof DOMText || $child instanceof DOMComment) { |
77 | continue; |
78 | } |
79 | |
80 | if (!($child instanceof DOMElement)) { |
81 | continue; |
82 | } |
83 | |
84 | $isMath = $params['isMath'] ?? false; |
85 | if ($child->tagName === 'math') { |
86 | $isMath = true; |
87 | } |
88 | |
89 | $childNodes = null; |
90 | if ($child->hasChildNodes()) { |
91 | $this->convertRootElementsRecursively( |
92 | iterator_to_array($child->childNodes), |
93 | ['isMath' => $isMath] |
94 | ); |
95 | $childNodes = $child->childNodes; |
96 | } |
97 | |
98 | $tagName = $child->tagName; |
99 | $nodeValue = $child->childElementCount === 0 ? $child->nodeValue : ''; |
100 | $convertedTag = $this->caseConversionService->kebabToCamelCase($tagName); |
101 | |
102 | if ($isMath) { |
103 | $convertedTag = 'm:' . $convertedTag; |
104 | } |
105 | |
106 | if (!$this->isTagValid($convertedTag)) { |
107 | common_Logger::w(sprintf('Invalid tag name: %s, When importing', $convertedTag)); |
108 | $child->remove(); |
109 | continue; |
110 | } |
111 | |
112 | $newElement = $child->ownerDocument->createElement($convertedTag, $nodeValue); |
113 | |
114 | if ($childNodes) { |
115 | foreach (iterator_to_array($childNodes) as $childNode) { |
116 | if ($childNode instanceof DOMElement) { |
117 | $newElement->appendChild($childNode); |
118 | } elseif ($childNode instanceof DOMText) { |
119 | if ($childNode->nodeValue !== $nodeValue) { |
120 | $newElement->appendChild($childNode); |
121 | } |
122 | } |
123 | } |
124 | } |
125 | $this->adjustAttributes($newElement, $child); |
126 | $child->parentNode->replaceChild($newElement, $child); |
127 | } |
128 | } |
129 | |
130 | private function adjustAttributes(DOMElement $newElement, DOMElement $childNode) |
131 | { |
132 | foreach (iterator_to_array($childNode->attributes) as $attribute) { |
133 | //If attribute name is not equal to nodeName it's most probably namespace attribute |
134 | //This will be hardcoded below therefore we wish to ignore it. |
135 | if ($attribute->name !== $attribute->nodeName) { |
136 | continue; |
137 | } |
138 | |
139 | if ($this->convertAttributesToQTI2($childNode, $newElement, $attribute)) { |
140 | continue; |
141 | } |
142 | |
143 | // Only replace attribute names from map |
144 | if ($attrReplacement = $this->caseConversionService->kebabToCamelCase($attribute->name)) { |
145 | $newElement->setAttribute($attrReplacement, $attribute->value); |
146 | continue; |
147 | } |
148 | |
149 | $newElement->setAttribute($attribute->name, $attribute->value); |
150 | } |
151 | } |
152 | |
153 | private function convertRootElement(DOMElement $rootElement): void |
154 | { |
155 | $newElement = $rootElement->ownerDocument->createElementNS( |
156 | self::QTI_22_NS, |
157 | $this->caseConversionService->kebabToCamelCase($this->getRootElement()) |
158 | ); |
159 | |
160 | $newElement->setAttributeNS( |
161 | self::XSI_NAMESPACE, |
162 | self::XSI_SCHEMA_LOCATION, |
163 | self::XSI_NAMESPACE |
164 | ); |
165 | |
166 | $newElement->setAttributeNS( |
167 | self::XSI_NAMESPACE, |
168 | self::XSI_SCHEMA_LOCATION, |
169 | self::SCHEMA_LOCATION |
170 | ); |
171 | |
172 | $newElement->setAttributeNS( |
173 | self::XSI_NAMESPACE, |
174 | self::XSI_SCHEMA_LOCATION, |
175 | self::SCHEMA_LOCATION |
176 | ); |
177 | |
178 | $newElement->setAttributeNS( |
179 | self::XML_NS_NAMESPACE, |
180 | self::QUALIFIED_NAME_XSI, |
181 | self::XSI_NAMESPACE |
182 | ); |
183 | |
184 | $newElement->setAttributeNS( |
185 | self::XML_NS_NAMESPACE, |
186 | self::QUALIFIED_NAME_MATH, |
187 | self::XSI_MATH_NAMESPACE |
188 | ); |
189 | |
190 | foreach (iterator_to_array($rootElement->childNodes) as $childNode) { |
191 | $newElement->appendChild($childNode); |
192 | } |
193 | |
194 | $this->adjustAttributes($newElement, $rootElement); |
195 | $rootElement->parentNode->replaceChild($newElement, $rootElement); |
196 | } |
197 | |
198 | abstract protected function getRootElement(): string; |
199 | |
200 | private function isTagValid(string $convertedTag): bool |
201 | { |
202 | $validationSchema = $this->validationService->getContentValidationSchema(self::QTI_22_NS); |
203 | foreach ($validationSchema as $schemaPath) { |
204 | $xsdDom = new DOMDocument(); |
205 | $xsdDom->load($schemaPath); |
206 | |
207 | $xpath = new DOMXPath($xsdDom); |
208 | $xpath->registerNamespace('xs', 'http://www.w3.org/2001/XMLSchema'); |
209 | |
210 | // Split prefix:localName |
211 | if (strpos($convertedTag, ':') !== false) { |
212 | list($prefix, $localName) = explode(':', $convertedTag, 2); |
213 | |
214 | $schemaRoot = $xsdDom->documentElement; |
215 | $namespaceUri = $schemaRoot->lookupNamespaceURI($prefix); |
216 | |
217 | if (!$namespaceUri) { |
218 | continue; // Try next schema |
219 | } |
220 | |
221 | $elements = $xpath->query("//xs:element[@name='$localName']"); |
222 | |
223 | foreach ($elements as $element) { |
224 | $schemaNs = $element->ownerDocument->documentElement->getAttribute('targetNamespace'); |
225 | if ($schemaNs === $namespaceUri) { |
226 | return true; |
227 | } |
228 | } |
229 | |
230 | $elementsByRef = $xpath->query("//xs:element[@ref='$convertedTag']"); |
231 | if ($elementsByRef->count() > 0) { |
232 | return true; |
233 | } |
234 | } else { |
235 | $elements = $xpath->query("//xs:element[@name='$convertedTag']"); |
236 | if ($elements->count() > 0) { |
237 | return true; |
238 | } |
239 | } |
240 | } |
241 | |
242 | return false; |
243 | } |
244 | |
245 | /** |
246 | * We can only apply conversion into root element equal to defined root element const |
247 | * We should ignore other dom object other DOMElement |
248 | * We should only apply if the namespace is equal to QTI 3.0 |
249 | */ |
250 | private function canBeConverted(mixed $child): bool |
251 | { |
252 | return $child instanceof DOMElement |
253 | && $child->tagName === $this->getRootElement() |
254 | && $child->getAttributeNode('xmlns') !== null |
255 | && $child->getAttributeNode('xmlns')->nodeValue === self::QTI_3_NS; |
256 | } |
257 | |
258 | private function convertAttributesToQTI2($childNode, $newElement, $attribute): bool |
259 | { |
260 | if ( |
261 | $childNode->nodeName === 'qti-extended-text-interaction' |
262 | && $this->isExpectedTextLinesEquivalent($attribute) |
263 | ) { |
264 | $classes = explode(' ', $attribute->value); |
265 | foreach ($classes as $class) { |
266 | if (strpos($class, 'qti-height-lines-') === 0) { |
267 | if (preg_match('/qti-height-lines-(\d+)/', $class, $matches)) { |
268 | $newElement->setAttribute('expectedLines', $matches[1]); |
269 | } |
270 | } |
271 | } |
272 | return true; |
273 | } |
274 | |
275 | if ( |
276 | $childNode->nodeName === 'qti-text-entry-interaction' |
277 | && $this->isExpectedTextLengthEquivalent($attribute) |
278 | ) { |
279 | $classes = explode(' ', $attribute->value); |
280 | foreach ($classes as $class) { |
281 | if (strpos($class, 'qti-input-width-') === 0) { |
282 | if (preg_match('/qti-input-width-(\d+)/', $class, $matches)) { |
283 | $newElement->setAttribute('expectedLength', $matches[1]); |
284 | } |
285 | } |
286 | } |
287 | return true; |
288 | } |
289 | |
290 | if ( |
291 | $childNode->nodeName === 'qti-choice-interaction' |
292 | && $this->isExpectedChoiceListStyleEquivalent($attribute) |
293 | ) { |
294 | $classes = explode(' ', $attribute->value); |
295 | $baseStyle = ''; |
296 | $suffixStyle = ''; |
297 | |
298 | foreach ($classes as $class) { |
299 | if (strpos($class, 'qti-labels-suffix-') === 0) { |
300 | $suffixStyle = str_replace('qti-labels-suffix-', '', $class); |
301 | } elseif (strpos($class, 'qti-labels-') === 0) { |
302 | $baseStyle = str_replace('qti-labels-', '', $class); |
303 | } |
304 | } |
305 | |
306 | if ($baseStyle !== '') { |
307 | $listStyle = 'list-style-' . $baseStyle; |
308 | if ($suffixStyle !== '' && $suffixStyle !== 'none') { |
309 | $listStyle .= '-' . $suffixStyle; |
310 | } |
311 | $newElement->setAttribute('class', $listStyle); |
312 | } |
313 | return true; |
314 | } |
315 | |
316 | return false; |
317 | } |
318 | |
319 | |
320 | private function isExpectedTextLengthEquivalent(DOMAttr $attribute): bool |
321 | { |
322 | return $attribute->name === 'class' && str_contains($attribute->value, 'qti-input-width-'); |
323 | } |
324 | |
325 | private function isExpectedTextLinesEquivalent(DOMAttr $attribute): bool |
326 | { |
327 | return $attribute->name === 'class' && str_contains($attribute->value, 'qti-height-lines-'); |
328 | } |
329 | |
330 | private function isExpectedChoiceListStyleEquivalent(DOMAttr $attribute): bool |
331 | { |
332 | return $attribute->name === 'class' && str_contains($attribute->value, 'qti-labels-'); |
333 | } |
334 | } |