Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.00% covered (success)
95.00%
38 / 40
66.67% covered (warning)
66.67%
2 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
Qti22PostProcessorService
95.00% covered (success)
95.00%
38 / 40
66.67% covered (warning)
66.67%
2 / 3
15
0.00% covered (danger)
0.00%
0 / 1
 itemContentPostProcessing
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
6
 registerAllNamespaces
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
4.59
 updateSchemaLocation
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
5
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) 2025 (original work) Open Assessment Technologies SA;
19 */
20
21namespace oat\taoQtiItem\model\Export;
22
23use DOMDocument;
24use DOMNodeList;
25use DOMXPath;
26
27class Qti22PostProcessorService
28{
29       /**
30     * @var array<string, string[]>  XPath expression => list of attributes to remove
31     */
32    protected array $removalRules = [
33        '//*[local-name()="img"][@type]'                         => ['type'],
34        '//*[local-name()="gapText"][@fixed]'                    => ['fixed'],
35        '//*[local-name()="gap"][@fixed]'                        => ['fixed'],
36        '//*[local-name()="gapImg"][@fixed]'                     => ['fixed'],
37        '//*[local-name()="associableHotspot"][@fixed]'          => ['fixed'],
38        '//qh5:figure[@showFigure]'                                => ['showFigure'],
39    ];
40
41    /**
42     * @var array<string,string>  namespace URI => schemaLocation URL
43     */
44    protected array $schemaLocationMapping = [
45        'http://www.imsglobal.org/xsd/imsqtiv2p2_html5_v1p0'
46            => 'https://purl.imsglobal.org/spec/qti/v2p2/schema/xsd/imsqtiv2p2p4_html5_v1p0.xsd',
47        'http://www.w3.org/1998/Math/MathML'
48            => 'http://www.w3.org/Math/XMLSchema/mathml2/mathml2.xsd',
49        'http://www.w3.org/2001/XInclude'
50            => 'https://www.imsglobal.org/xsd/w3/2001/XInclude.xsd',
51    ];
52
53    /**
54     * Post-processes a QTI item XML string and returns the cleaned XML along with configs.
55     */
56    public function itemContentPostProcessing(string $content): string
57    {
58        $dom = new DOMDocument();
59        $dom->preserveWhiteSpace = false;
60        $dom->formatOutput       = true;
61        $dom->loadXML($content);
62
63        $xpath = new DOMXPath($dom);
64        $this->registerAllNamespaces($dom, $xpath);
65
66        // Apply removal rules
67        foreach ($this->removalRules as $expr => $attrs) {
68            $nodes = $xpath->query($expr);
69            if ($nodes instanceof DOMNodeList) {
70                foreach ($nodes as $node) {
71                    foreach ($attrs as $attr) {
72                        if ($node->hasAttribute($attr)) {
73                            $node->removeAttribute($attr);
74                        }
75                    }
76                }
77            }
78        }
79
80        // Update xsi:schemaLocation entries
81        $this->updateSchemaLocation($dom);
82
83        return $dom->saveXML();
84    }
85
86    /**
87     * Registers all xmlns:* (including default) from root into XPath
88     */
89    protected function registerAllNamespaces(DOMDocument $dom, DOMXPath $xpath): void
90    {
91        $root = $dom->documentElement;
92        foreach ($root->attributes as $attr) {
93            if (strpos($attr->nodeName, 'xmlns') === 0) {
94                $prefix = ($attr->prefix === 'xmlns') ? $attr->localName : '';
95                $xpath->registerNamespace($prefix, $attr->nodeValue);
96            }
97        }
98        $xpath->registerNamespace('xsi', 'http://www.w3.org/2001/XMLSchema-instance');
99    }
100
101    /**
102     * Merges existing xsi:schemaLocation with configured mappings
103     */
104    protected function updateSchemaLocation(DOMDocument $dom): void
105    {
106        $root     = $dom->documentElement;
107        $existing = $root->getAttributeNS(
108            'http://www.w3.org/2001/XMLSchema-instance',
109            'schemaLocation'
110        );
111        $parts = preg_split('/\s+/', trim($existing)) ?: [];
112        $map = [];
113        for ($i = 0; $i + 1 < count($parts); $i += 2) {
114            $map[$parts[$i]] = $parts[$i + 1];
115        }
116        // Merge with mappings
117        foreach ($this->schemaLocationMapping as $ns => $xsd) {
118            $map[$ns] = $xsd;
119        }
120
121        $schemaList = '';
122        foreach ($map as $nsUri => $xsdUri) {
123            $schemaList .= $nsUri . ' ' . $xsdUri . ' ';
124        }
125        $root->setAttributeNS(
126            'http://www.w3.org/2001/XMLSchema-instance',
127            'xsi:schemaLocation',
128            trim($schemaList)
129        );
130    }
131}