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 | } |