Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
60.93% covered (warning)
60.93%
92 / 151
27.27% covered (danger)
27.27%
3 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
AbstractQtiConverter
60.93% covered (warning)
60.93%
92 / 151
27.27% covered (danger)
27.27%
3 / 11
299.76
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 convertToQti2
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 convertRootElementsRecursively
61.76% covered (warning)
61.76%
21 / 34
0.00% covered (danger)
0.00%
0 / 1
27.58
 adjustAttributes
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
5.27
 convertRootElement
96.97% covered (success)
96.97%
32 / 33
0.00% covered (danger)
0.00%
0 / 1
2
 getRootElement
n/a
0 / 0
n/a
0 / 0
0
 isTagValid
41.67% covered (danger)
41.67%
10 / 24
0.00% covered (danger)
0.00%
0 / 1
20.70
 canBeConverted
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 convertAttributesToQTI2
21.21% covered (danger)
21.21%
7 / 33
0.00% covered (danger)
0.00%
0 / 1
195.56
 isExpectedTextLengthEquivalent
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 isExpectedTextLinesEquivalent
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 isExpectedChoiceListStyleEquivalent
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
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
21declare(strict_types=1);
22
23namespace oat\taoQtiItem\model\qti\converter;
24
25use common_Logger;
26use DOMAttr;
27use DOMComment;
28use DOMDocument;
29use DOMElement;
30use DOMText;
31use DOMXPath;
32use oat\taoQtiItem\model\ValidationService;
33
34abstract 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}