Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
82.22% covered (warning)
82.22%
74 / 90
50.00% covered (danger)
50.00%
4 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
AbstractQtiConverter
82.22% covered (warning)
82.22%
74 / 90
50.00% covered (danger)
50.00%
4 / 8
36.40
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
60.00% covered (warning)
60.00%
15 / 25
0.00% covered (danger)
0.00%
0 / 1
18.74
 adjustAttributes
70.00% covered (warning)
70.00%
7 / 10
0.00% covered (danger)
0.00%
0 / 1
5.68
 convertRootElement
96.43% covered (success)
96.43%
27 / 28
0.00% covered (danger)
0.00%
0 / 1
2
 getRootElement
n/a
0 / 0
n/a
0 / 0
0
 isTagValid
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
3.05
 canBeConverted
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 isExpectedLengthEquivalent
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
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_SCHEMA_LOCATION = 'xsi:schemaLocation';
40    private const SCHEMA_LOCATION = 'http://www.imsglobal.org/xsd/imsqti_v2p2 ' .
41    'http://www.imsglobal.org/xsd/qti/qtiv2p2/imsqti_v2p2p1.xsd';
42    private const XML_NS_NAMESPACE = 'http://www.w3.org/2000/xmlns/';
43    private const QUALIFIED_NAME_NS = 'xmlns';
44    private const QUALIFIED_NAME_XSI = self::QUALIFIED_NAME_NS . ':xsi';
45
46    private CaseConversionService $caseConversionService;
47    private ValidationService $validationService;
48
49    public function __construct(CaseConversionService $caseConversionService, ValidationService $validationService)
50    {
51        $this->caseConversionService = $caseConversionService;
52        $this->validationService = $validationService;
53    }
54
55    public function convertToQti2(string $filename): void
56    {
57        // Load the QTI XML document
58        $dom = new DOMDocument();
59        $dom->preserveWhiteSpace = false;
60        $dom->formatOutput = true;
61        $dom->load($filename);
62        foreach (iterator_to_array($dom->childNodes) as $child) {
63            if ($this->canBeConverted($child)) {
64                $this->convertRootElementsRecursively(iterator_to_array($child->childNodes));
65                $this->convertRootElement($child);
66                $dom->save($filename);
67            }
68        }
69    }
70
71    private function convertRootElementsRecursively(array $children): void
72    {
73        foreach ($children as $child) {
74            if ($child instanceof DOMText || $child instanceof DOMComment) {
75                continue;
76            }
77
78            if ($child instanceof DOMElement) {
79                $childNodes = null;
80                if ($child->hasChildNodes()) {
81                    $this->convertRootElementsRecursively(iterator_to_array($child->childNodes));
82                    $childNodes = $child->childNodes;
83                }
84            }
85
86            $tagName = $child->tagName;
87            // When elements has child we do not want to create literal value
88            $nodeValue = $child->childElementCount === 0 ? $child->nodeValue : '';
89
90            $convertedTag = $this->caseConversionService->kebabToCamelCase($tagName);
91            // Check if converted tag name is valid against defined QTI 2.2 namespace
92            if (!$this->isTagValid($convertedTag)) {
93                common_Logger::w(sprintf('Invalid tag name: %s, When importing', $convertedTag));
94                $child->remove();
95                continue;
96            }
97
98            $newElement = $child->ownerDocument->createElement(
99                $convertedTag,
100                $nodeValue
101            );
102
103            if ($childNodes) {
104                foreach (iterator_to_array($childNodes) as $childNode) {
105                    if ($childNode instanceof DOMElement) {
106                        $newElement->appendChild($childNode);
107                    }
108                }
109            }
110
111            $this->adjustAttributes($newElement, $child);
112            $child->parentNode->replaceChild($newElement, $child);
113        }
114    }
115
116    private function adjustAttributes(DOMElement $newElement, DOMElement $childNode)
117    {
118        foreach (iterator_to_array($childNode->attributes) as $attribute) {
119            //If attribute name is not equal to nodeName it's most probably namespace attribute
120            //This will be hardcoded below therefore we wish to ignore it.
121            if ($attribute->name !== $attribute->nodeName) {
122                continue;
123            }
124
125            if ($this->isExpectedLengthEquivalent($attribute)) {
126                $newElement->setAttribute('expectedLength', str_replace('qti-input-width-', '', $attribute->value));
127                continue;
128            }
129
130            // Only replace attribute names from map
131            if ($attrReplacement = $this->caseConversionService->kebabToCamelCase($attribute->name)) {
132                $newElement->setAttribute($attrReplacement, $attribute->value);
133                continue;
134            }
135
136            $newElement->setAttribute($attribute->name, $attribute->value);
137        }
138    }
139
140    private function convertRootElement(DOMElement $rootElement): void
141    {
142        $newElement = $rootElement->ownerDocument->createElementNS(
143            self::QTI_22_NS,
144            $this->caseConversionService->kebabToCamelCase($this->getRootElement())
145        );
146
147        $newElement->setAttributeNS(
148            self::XSI_NAMESPACE,
149            self::XSI_SCHEMA_LOCATION,
150            self::XSI_NAMESPACE
151        );
152
153        $newElement->setAttributeNS(
154            self::XSI_NAMESPACE,
155            self::XSI_SCHEMA_LOCATION,
156            self::SCHEMA_LOCATION
157        );
158
159        $newElement->setAttributeNS(
160            self::XSI_NAMESPACE,
161            self::XSI_SCHEMA_LOCATION,
162            self::SCHEMA_LOCATION
163        );
164
165        $newElement->setAttributeNS(
166            self::XML_NS_NAMESPACE,
167            self::QUALIFIED_NAME_XSI,
168            self::XSI_NAMESPACE
169        );
170
171        foreach (iterator_to_array($rootElement->childNodes) as $childNode) {
172            $newElement->appendChild($childNode);
173        }
174
175        $this->adjustAttributes($newElement, $rootElement);
176        $rootElement->parentNode->replaceChild($newElement, $rootElement);
177    }
178
179    abstract protected function getRootElement(): string;
180
181    private function isTagValid(string $convertedTag): bool
182    {
183        $validationSchema = $this->validationService->getContentValidationSchema(self::QTI_22_NS);
184        foreach ($validationSchema as $schema) {
185            $xsdDom = new DOMDocument();
186            $xsdDom->load($schema);
187            $xpath = new DOMXPath($xsdDom);
188            $xpath->registerNamespace('xs', 'http://www.w3.org/2001/XMLSchema');
189            $elements = $xpath->query(sprintf("//xs:element[@name='%s']", $convertedTag));
190
191            if ($elements->count() === 0) {
192                return false;
193            }
194
195            return true;
196        }
197
198        return false;
199    }
200
201    /**
202     * We can only apply conversion into root element equal to defined root element const
203     * We should ignore other dom object other DOMElement
204     * We should only apply if the namespace is equal to QTI 3.0
205     */
206    private function canBeConverted(mixed $child): bool
207    {
208        return $child instanceof DOMElement
209            && $child->tagName === $this->getRootElement()
210            && $child->getAttributeNode('xmlns') !== null
211            && $child->getAttributeNode('xmlns')->nodeValue === self::QTI_3_NS;
212    }
213
214    private function isExpectedLengthEquivalent(DOMAttr $attribute): bool
215    {
216        return $attribute->name === 'class' && str_contains($attribute->value, 'qti-input-width-');
217    }
218}