diff --git a/qtism/data/XInclude.php b/qtism/data/XInclude.php new file mode 100644 index 000000000..255633c96 --- /dev/null +++ b/qtism/data/XInclude.php @@ -0,0 +1,106 @@ + + * + */ +class XInclude extends ExternalQtiComponent implements BlockStatic, FlowStatic, InlineStatic, OutcomeRule, ResponseRule { + + /** + * A base URI. + * + * @var string + * @qtism-bean-property + */ + private $xmlBase = ''; + + /** + * Create a new XInclude object. + * + * @param string $xmlString The XML Content of the node. + */ + public function __construct($xmlString) { + parent::__construct($xmlString); + } + + /** + * Get the value of the xi:href attribute in the XML content. + * + * This is a convenience method. + * + * @return string + */ + public function getHref() { + $xml = $this->getXml(); + return $xml->documentElement->getAttribute('href'); + } + + /** + * @see \qtism\data\content\Flow::setXmlBase() + */ + public function setXmlBase($xmlBase = '') + { + if (is_string($xmlBase) && (empty($xmlBase) || Format::isUri($xmlBase))) { + $this->xmlBase = $xmlBase; + } else { + $msg = "The 'xmlBase' argument must be an empty string or a valid URI, '" . $xmlBase . "' given"; + throw new InvalidArgumentException($msg); + } + } + + /** + * @see \qtism\data\content\Flow::getXmlBase() + */ + public function getXmlBase() + { + return $this->xmlBase; + } + + /** + * @see \qtism\data\content\Flow::hasXmlBase() + */ + public function hasXmlBase() + { + return $this->getXmlBase() !== ''; + } + + /** + * @see \qtism\data\ExternalQtiComponent::getQtiClassName() + */ + public function getQtiClassName() + { + return 'include'; + } + + /** + * @see \qtism\data\ExternalQtiComponent::buildTargetNamespace() + */ + protected function buildTargetNamespace() { + $this->setTargetNamespace('http://www.w3.org/2001/XInclude'); + } +} diff --git a/qtism/data/content/AtomicInline.php b/qtism/data/content/AtomicInline.php index 4febd0c04..d80d4bd5f 100644 --- a/qtism/data/content/AtomicInline.php +++ b/qtism/data/content/AtomicInline.php @@ -23,6 +23,7 @@ namespace qtism\data\content; +use qtism\common\utils\Format; use qtism\data\QtiComponentCollection; use \InvalidArgumentException; diff --git a/qtism/data/storage/xml/XmlDocument.php b/qtism/data/storage/xml/XmlDocument.php index 3f3380602..b40ba005a 100644 --- a/qtism/data/storage/xml/XmlDocument.php +++ b/qtism/data/storage/xml/XmlDocument.php @@ -24,19 +24,25 @@ namespace qtism\data\storage\xml; +use qtism\common\utils\Url; +use qtism\data\QtiComponentCollection; +use qtism\data\QtiComponentIterator; use qtism\data\QtiDocument; use qtism\data\storage\xml\marshalling\MarshallerFactory; use qtism\data\AssessmentTest; +use qtism\data\content\Flow; use qtism\data\storage\xml\marshalling\Marshaller; use qtism\data\storage\xml\marshalling\UnmarshallingException; use qtism\data\QtiComponent; use qtism\data\storage\Utils as StorageUtils; use qtism\data\storage\xml\Utils as XmlUtils; +use \ReflectionClass; use \DOMDocument; use \DOMElement; use \DOMException; use \RuntimeException; use \InvalidArgumentException; +use \LogicException; /** * This class represents a QTI-XML Document. @@ -292,6 +298,63 @@ public function schemaValidate($filename = '') { } } + + /** + * Resolve include components. + * + * After the item has been loaded using the load or loadFromString method, + * the include components can be resolved by calling this method. Files will + * be included following the rules described by the XInclude specification. + * + * @param boolean $validate Whether or not validate files being included. + * @throws \LogicException If the method is called prior the load or loadFromString method was called. + * @throws \qtism\data\storage\xml\XmlStorageException If an error occured while parsing or validating files to be included. + */ + public function xInclude($validate = false) { + + if (($root = $this->getDocumentComponent()) !== false) { + + $baseUri = str_replace('\\', '/', $this->getDomDocument()->documentElement->baseURI); + $pathinfo = pathinfo($baseUri); + $basePath = $pathinfo['dirname']; + + $iterator = new QtiComponentIterator($root, array('include')); + foreach ($iterator as $include) { + $parent = $iterator->parent(); + + // Is the parent something we can deal with for replacement? + $reflection = new ReflectionClass($parent); + + if ($reflection->hasMethod('getContent') === true && $parent->getContent() instanceof QtiComponentCollection) { + $href = $include->getHref(); + + if (Url::isRelative($href) === true) { + $href = Url::rtrim($basePath) . '/' . Url::ltrim($href); + + $doc = new XmlDocument(); + $doc->load($href, $validate); + $includeRoot = $doc->getDocumentComponent(); + + if ($includeRoot instanceof Flow) { + // Derive xml:base... + $xmlBase = Url::ltrim(str_replace($basePath, '', $href)); + $xmlBasePathInfo = pathinfo($xmlBase); + + if ($xmlBasePathInfo['dirname'] !== '.') { + $includeRoot->setXmlBase($xmlBasePathInfo['dirname'] . '/'); + } + } + + $parent->getContent()->replace($include, $includeRoot); + } + } + } + } else { + $msg = "Cannot include fragments prior to loading any file."; + throw new LogicException($msg); + } + } + /** * Decorate the root element of the XmlAssessmentDocument with the appropriate * namespaces and schema definition. diff --git a/qtism/data/storage/xml/marshalling/ContentMarshaller.php b/qtism/data/storage/xml/marshalling/ContentMarshaller.php index 4a0586d80..4b4e81206 100644 --- a/qtism/data/storage/xml/marshalling/ContentMarshaller.php +++ b/qtism/data/storage/xml/marshalling/ContentMarshaller.php @@ -88,7 +88,7 @@ public function __construct() { 'selectPointInteraction', 'associableHotspot', 'hotspotChoice', 'graphicGapMatchInteraction', 'positionObjectInteraction', 'positionObjectStage', 'sliderInteraction', 'mediaInteraction', 'drawingInteraction', 'uploadInteraction', 'endAttemptInteraction', 'customInteraction', - 'printedVariable', 'math'); + 'printedVariable', 'math', 'include'); private static $simpleComposites = array('a', 'abbr', 'acronym', 'b', 'big', 'cite', 'code', 'dfn', 'em', 'feedbackInline', 'templateInline', 'i', 'kbd', 'q', 'samp', 'small', 'span', 'strong', 'sub', 'sup', 'tt', 'var', 'td', 'th', 'object', diff --git a/qtism/data/storage/xml/marshalling/ImgMarshaller.php b/qtism/data/storage/xml/marshalling/ImgMarshaller.php index ccf72272a..03afebe4c 100644 --- a/qtism/data/storage/xml/marshalling/ImgMarshaller.php +++ b/qtism/data/storage/xml/marshalling/ImgMarshaller.php @@ -63,7 +63,7 @@ protected function marshall(QtiComponent $component) { } if ($component->hasXmlBase() === true) { - self::setXmlBase($element, $component->setXmlBase()); + self::setXmlBase($element, $component->getXmlBase()); } self::fillElement($element, $component); diff --git a/qtism/data/storage/xml/marshalling/MarshallerFactory.php b/qtism/data/storage/xml/marshalling/MarshallerFactory.php index 165e59239..739b529ac 100644 --- a/qtism/data/storage/xml/marshalling/MarshallerFactory.php +++ b/qtism/data/storage/xml/marshalling/MarshallerFactory.php @@ -160,6 +160,7 @@ public function __construct() { $this->addMappingEntry('templateBlock', 'qtism\\data\\storage\\xml\\marshalling\\TemplateElementMarshaller'); $this->addMappingEntry('hotspotChoice', 'qtism\\data\\storage\\xml\\marshalling\\HotspotMarshaller'); $this->addMappingEntry('associableHotspot', 'qtism\\data\\storage\\xml\\marshalling\\HotspotMarshaller'); + $this->addMappingEntry('include', 'qtism\\data\\storage\\xml\\marshalling\\XIncludeMarshaller'); } /** diff --git a/qtism/data/storage/xml/marshalling/XIncludeMarshaller.php b/qtism/data/storage/xml/marshalling/XIncludeMarshaller.php new file mode 100644 index 000000000..1749ed70e --- /dev/null +++ b/qtism/data/storage/xml/marshalling/XIncludeMarshaller.php @@ -0,0 +1,70 @@ + + * @license GPLv2 + */ + +namespace qtism\data\storage\xml\marshalling; + +use qtism\data\XInclude; +use qtism\data\QtiComponent; +use \DOMElement; + +/** + * Marshalling/Unmarshalling implementation for Include. + * + * @author Jérôme Bogaerts + * + */ +class XIncludeMarshaller extends Marshaller +{ + /** + * Marshall an XInclude object into a DOMElement object. + * + * @param \qtism\data\QtiComponent $component An XInclude object. + * @return \DOMElement The according DOMElement object. + * @throws \qtism\data\storage\marshalling\MarshallingException + */ + protected function marshall(QtiComponent $component) + { + return self::getDOMCradle()->importNode($component->getXml()->documentElement, true); + } + + /** + * Unmarshall a DOMElement object corresponding to a math element. + * + * @param \DOMElement $element A DOMElement object. + * @return \qtism\data\QtiComponent A Math object. + * @throws \qtism\data\storage\marshalling\UnmarshallingException + */ + protected function unmarshall(DOMElement $element) + { + $node = $element->cloneNode(true); + + return new XInclude($element->ownerDocument->saveXML($node)); + } + + /** + * @see \qtism\data\storage\xml\marshalling\Marshaller::getExpectedQtiClassName() + */ + public function getExpectedQtiClassName() + { + return 'include'; + } +} diff --git a/test/qtism/data/storage/xml/XmlDocumentXIncludeTest.php b/test/qtism/data/storage/xml/XmlDocumentXIncludeTest.php new file mode 100644 index 000000000..9cc12150b --- /dev/null +++ b/test/qtism/data/storage/xml/XmlDocumentXIncludeTest.php @@ -0,0 +1,80 @@ +load(self::samplesDir() . 'custom/items/xinclude/xinclude_ns_in_tag.xml', true); + + $includes = $doc->getDocumentComponent()->getComponentsByClassName('include'); + $this->assertEquals(1, count($includes)); + $this->assertEquals('xinclude_ns_in_tag_content1.xml', $includes[0]->getHref()); + + $file = tempnam('/tmp', 'qsm'); + $doc->save($file); + + // Does it validate again? + $doc = new XmlDocument(); + try { + $doc->load($file, true); + $this->assertTrue(true); + } catch (XmlStorageException $e) { + $this->assertFalse(true, "The document using xinclude should validate after being saved."); + } + } + + /** + * @depends testLoadAndSaveXIncludeNsInTag + */ + public function testLoadAndResolveXIncludeSameBase() { + $doc = new XmlDocument(); + $doc->load(self::samplesDir() . 'custom/items/xinclude/xinclude_ns_in_tag.xml', true); + + // At this moment, includes are not resolved. + $includes = $doc->getDocumentComponent()->getComponentsByClassName('include'); + $this->assertEquals(1, count($includes)); + // So no img components can be found... + $imgs = $doc->getDocumentComponent()->getComponentsByClassName('img'); + $this->assertEquals(0, count($imgs)); + + $doc->xInclude(); + + // Now they are! + $includes = $doc->getDocumentComponent()->getComponentsByClassName('include'); + $this->assertEquals(0, count($includes)); + + // And we should find an img component then! + $imgs = $doc->getDocumentComponent()->getComponentsByClassName('img'); + $this->assertEquals(1, count($imgs)); + + // Check that xml:base was appropriately resolved. In this case, + // no content for xml:base because 'xinclude_ns_in_tag_content1.xml' is in the + // same directory as the main xml file. + $this->assertEquals('', $imgs[0]->getXmlBase()); + } + + /** + * @depends testLoadAndResolveXIncludeSameBase + */ + public function testLoadAndResolveXIncludeDifferentBase() { + $doc = new XmlDocument(); + $doc->load(self::samplesDir() . 'custom/items/xinclude/xinclude_ns_in_tag_subfolder.xml', true); + $doc->xInclude(); + + $includes = $doc->getDocumentComponent()->getComponentsByClassName('include'); + $this->assertEquals(0, count($includes)); + + // And we should find an img component then! + $imgs = $doc->getDocumentComponent()->getComponentsByClassName('img'); + $this->assertEquals(1, count($imgs)); + + // Check that xml:base was appropriately resolved. In this case, + // no content for xml:base because 'xinclude_ns_in_tag_content1.xml' is in the + // same directory as the main xml file. + $this->assertEquals('subfolder/', $imgs[0]->getXmlBase()); + } +} \ No newline at end of file diff --git a/test/qtism/data/storage/xml/marshalling/XIncludeMarshallerTest.php b/test/qtism/data/storage/xml/marshalling/XIncludeMarshallerTest.php new file mode 100644 index 000000000..e9c2e53b4 --- /dev/null +++ b/test/qtism/data/storage/xml/marshalling/XIncludeMarshallerTest.php @@ -0,0 +1,39 @@ +'); + $element = $this->getMarshallerFactory('2.1.0')->createMarshaller($xinclude)->marshall($xinclude); + + $dom = new DOMDocument('1.0', 'UTF-8'); + $element = $dom->importNode($element, true); + $this->assertEquals('', $dom->saveXML($element)); + } + + public function testUnmarshall() { + $element = $this->createDOMElement(''); + + $xinclude = $this->getMarshallerFactory('2.1.0')->createMarshaller($element)->unmarshall($element); + $this->assertInstanceOf('qtism\\data\\XInclude', $xinclude); + $this->assertEquals('path/to/file', $xinclude->getHref()); + $xml = $xinclude->getXml(); + $this->assertInstanceOf('\\DOMDocument', $xml); + + $includeElement = $xml->documentElement; + $this->assertEquals('xi', $includeElement->prefix); + $this->assertEquals('http://www.w3.org/2001/XInclude', $includeElement->namespaceURI); + } + + public function testGetXmlWrongNamespace() { + $element = $this->createDOMElement(''); + + $xinclude = $this->getMarshallerFactory('2.1.0')->createMarshaller($element)->unmarshall($element); + $this->setExpectedException('\\RuntimeException'); + $xml = $xinclude->getXml(); + } +} diff --git a/test/samples/custom/items/xinclude/subfolder/xinclude_ns_in_tag_content1.xml b/test/samples/custom/items/xinclude/subfolder/xinclude_ns_in_tag_content1.xml new file mode 100644 index 000000000..7ae227266 --- /dev/null +++ b/test/samples/custom/items/xinclude/subfolder/xinclude_ns_in_tag_content1.xml @@ -0,0 +1,2 @@ + +NEVER LEAVE LUGGAGE UNATTENDED \ No newline at end of file diff --git a/test/samples/custom/items/xinclude/xinclude_ns_in_tag.xml b/test/samples/custom/items/xinclude/xinclude_ns_in_tag.xml new file mode 100644 index 000000000..4cc17b86a --- /dev/null +++ b/test/samples/custom/items/xinclude/xinclude_ns_in_tag.xml @@ -0,0 +1,31 @@ + + + + + + ChoiceA + + + + + 0 + + + +

Look at the text in the picture.

+

+ +

+ + What does it say? + You must stay with your luggage at all times. + Do not let someone else look after your luggage. + Remember your luggage when you leave. + +
+ +
diff --git a/test/samples/custom/items/xinclude/xinclude_ns_in_tag_content1.xml b/test/samples/custom/items/xinclude/xinclude_ns_in_tag_content1.xml new file mode 100644 index 000000000..7ae227266 --- /dev/null +++ b/test/samples/custom/items/xinclude/xinclude_ns_in_tag_content1.xml @@ -0,0 +1,2 @@ + +NEVER LEAVE LUGGAGE UNATTENDED \ No newline at end of file diff --git a/test/samples/custom/items/xinclude/xinclude_ns_in_tag_subfolder.xml b/test/samples/custom/items/xinclude/xinclude_ns_in_tag_subfolder.xml new file mode 100644 index 000000000..09c5e04cf --- /dev/null +++ b/test/samples/custom/items/xinclude/xinclude_ns_in_tag_subfolder.xml @@ -0,0 +1,31 @@ + + + + + + ChoiceA + + + + + 0 + + + +

Look at the text in the picture.

+

+ +

+ + What does it say? + You must stay with your luggage at all times. + Do not let someone else look after your luggage. + Remember your luggage when you leave. + +
+ +