5 * Copyright 2018 Andy Street <andy@street.me.uk>
7 * This program is free software; you can redistribute it and/or modify
8 * it under the terms of the GNU General Public License as published by
9 * the Free Software Foundation; either version 2 of the License, or
10 * (at your option) any later version.
12 * This program is distributed in the hope that it will be useful,
13 * but WITHOUT ANY WARRANTY; without even the implied warranty of
14 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 * GNU General Public License for more details.
17 * You should have received a copy of the GNU General Public License
18 * along with this program; if not, write to the Free Software
19 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
30 use \InvalidArgumentException;
31 use \RuntimeException;
36 * @author Andy Street <andy@street.me.uk>
42 * The namespace of the XML currently being parsed.
49 * The GPX version of the XML currently being parsed.
56 * The reader currently reading the XML.
63 * Read GPX from a string.
65 * @param string $xml The GPX XML data.
66 * @return GPX The GPX data.
67 * @throws RuntimeException If the XML could not be read.
68 * @throws MalformedGPXException If the GPX file is invalid.
70 public function readString(string $xml)
73 $this->xml = new XMLReader();
74 if (!$this->xml->XML($xml, null, LIBXML_NOERROR | LIBXML_NOWARNING)) {
75 throw new RuntimeException('Unable to read XML from string.');
84 * Read GPX from a file.
86 * This function is an alias of `GPXReader::readURI()`.
88 * @param string $filename The name of the file containing GPX XML data.
89 * @return GPX The GPX data.
90 * @throws RuntimeException If the XML could not be read.
91 * @throws MalformedGPXException If the GPX file is invalid.
93 public function readFile(string $filename)
95 return $this->readURI($filename);
99 * Read GPX from a URI.
101 * @param string $uri The location of the file containing GPX XML data.
102 * @return GPX The GPX data.
103 * @throws RuntimeException If the XML could not be read.
104 * @throws MalformedGPXException If the GPX file is invalid.
106 public function readURI(string $uri)
109 $this->xml = new XMLReader();
110 if (!$this->xml->open($uri, null, LIBXML_NOERROR | LIBXML_NOWARNING)) {
111 throw new RuntimeException(sprintf('Unable to read XML from: %s', $uri));
113 return $this->read();
120 * Read GPX data from XML.
122 * @param XMLReader $xml Where to read the GPX data from.
123 * @return GPX The data that was read.
124 * @throws MalformedGPXException If the GPX file was invalid or not supported.
126 protected function read()
130 // Fast forward to the root element.
131 while ($xml->nodeType !== XMLReader::ELEMENT) {
135 // Detect file version
136 switch ($xml->namespaceURI) {
137 case libgpx::NAMESPACE_GPX_1_0:
138 $this->version = '1.0';
139 $this->ns = libgpx::NAMESPACE_GPX_1_0;
141 case libgpx::NAMESPACE_GPX_1_1:
142 $this->version = '1.1';
143 $this->ns = libgpx::NAMESPACE_GPX_1_1;
146 throw new MalformedGPXException('Unknown or unsupported file format.');
150 if ($xml->localName == 'gpx')
151 return $this->readGPX();
153 throw new MalformedGPXException('Root element must be "gpx".');
156 $this->version = null;
164 * @return GPX The GPX data.
165 * @throws MalformedGPXException If the GPX file was invalid or not supported.
167 protected function readGPX()
169 // Sanity check - ensure version attribute and schema declaration match.
170 if ($this->xml->getAttribute('version') != $this->version)
171 throw new MalformedGPXException(
172 'GPX version attribute does not match namespace declaration.'
176 $result->setCreator($this->xml->getAttribute('creator'));
181 if ($this->version == '1.1') {
182 $struct['elements']['metadata'] = function ($gpx) {
183 $this->readMetadata($gpx);
186 $struct['elements']['name'] = function ($gpx) {
187 $gpx->setName($this->readXSDString());
189 $struct['elements']['desc'] = function ($gpx) {
190 $gpx->setDesc($this->readXSDString());
192 $struct['elements']['author'] = function ($gpx) {
193 $author = $gpx->getAuthor();
194 if ($author === null) {
195 $author = new Person();
196 $gpx->setAuthor($author);
198 $author->setName($this->readXSDString());
200 $struct['elements']['email'] = function ($gpx) {
201 $author = $gpx->getAuthor();
202 if ($author === null) {
203 $author = new Person();
204 $gpx->setAuthor($author);
206 $author->setEmail($this->readXSDString());
208 $struct['elements']['url'] = function ($gpx, &$state) {
209 $href = $this->readXSDString();
210 $links = $gpx->getLinks();
211 if ($links->isEmpty) {
212 $link = new Link($href);
213 if (isset($state['urlname'])) {
214 $link->setText($state['urlname']);
215 unset($state['urlname']);
219 $links->bottom()->setHref($href);
222 $struct['elements']['urlname'] = function ($gpx, &$state) {
223 $text = $this->readXSDString();
224 $links = $gpx->getLinks();
225 if ($links->isEmpty) {
226 $state['urlname'] = $text;
228 $links->bottom()->setText($text);
231 $struct['elements']['time'] = function ($gpx) {
232 $gpx->setTime($this->string2DateTime($this->readXSDString()));
234 $struct['elements']['keywords'] = function ($gpx) {
235 $keywords = $gpx->getKeywords();
236 $words = explode(',', $this->readXSDString());
237 foreach ($words as $word)
238 $keywords[] = trim($word);
240 $struct['elements']['bounds'] = false;
243 $this->readStruct($struct, $result);
248 * Read a metadata type.
250 * Note: This is GPX 1.1 only as GPX 1.0 does not have a metadata type.
252 * @param GPX $gpx The gpx object to populate.
254 * @throws MalformedGPXException If the GPX file was invalid or not supported.
256 protected function readMetadata(GPX $gpx)
260 'name' => function ($gpx) {
261 $gpx->setName($this->readXSDString());
263 'desc' => function ($gpx) {
264 $gpx->setDesc($this->readXSDString());
266 'author' => function ($gpx) {
267 $gpx->setAuthor($this->readPerson());
269 'copyright' => function ($gpx) {
270 $gpx->setCopyright($this->readCopyright());
272 'link' => function ($gpx) {
273 $links = $gpx->getLinks();
274 $links[] = $this->readLink();
276 'time' => function ($gpx) {
277 $gpx->setTime($this->string2DateTime($this->readXSDString()));
279 'keywords' => function ($gpx) {
280 $keywords = $gpx->getKeywords();
281 $words = explode(',', $this->readXSDString());
282 foreach ($words as $word)
283 $keywords[] = trim($word);
285 'bounds' => false, // Not required as it is calculated internally.
286 'extensions' => function ($gpx) {
287 $this->readExtension($gpx->getMetadataExtensions());
291 $this->readStruct($struct, $gpx);
295 * Read a person type.
297 * @return Person The person data.
298 * @throws MalformedGPXException If the GPX file was invalid or not supported.
300 protected function readPerson()
304 'name' => function ($person) {
305 $person->setName($this->readXSDString());
307 'email' => function ($person) {
308 $id = $this->xml->getAttribute('id');
310 throw new MalformedGPXException(
311 'Missing required attribute "id" in "email"'
313 $domain = $this->xml->getAttribute('domain');
314 if ($domain === null)
315 throw new MalformedGPXException(
316 'Missing required attribute "domain" in "email"'
318 $person->setEmail($id . '@' . $domain);
321 'link' => function ($person) {
322 $person->setLink($this->readLink());
326 $person = new Person();
327 $this->readStruct($struct, $person);
334 * @return Link The link data.
335 * @throws MalformedGPXException If the GPX file was invalid or not supported.
337 protected function readLink()
339 $href = $this->xml->getAttribute('href');
341 throw new MalformedGPXException(
342 'Missing required attribute "href" in "link"'
346 'text' => function ($link) {
347 $link->setText($this->readXSDString());
349 'type' => function ($link) {
350 $link->setType($this->readXSDString());
354 $link = new Link($href);
355 $this->readStruct($struct, $link);
360 * Read a copyright type.
362 * @return Copyright The copyright data.
363 * @throws MalformedGPXException If the GPX file was invalid or not supported.
365 protected function readCopyright()
367 $author = $this->xml->getAttribute('author');
368 if ($author === null)
369 throw new MalformedGPXException(
370 'Missing required attribute "author" in "copyright"'
374 'year' => function ($copyright) {
376 $year = $this->readXSDString();
377 $copyright->setYear($year);
378 } catch (InvalidArgumentException $e) {
379 throw new MalformedGPXException(
380 sprintf('"%s" is not a valid year', $year)
384 'license' => function ($copyright) {
385 $copyright->setLicense($this->readXSDString());
389 $copyright = new Copyright($author);
390 $this->readStruct($struct, $copyright);
395 * Read an extensions type.
397 * This is suitable for GPX 1.1 only as private elements are mixed into other
398 * elements in GPX 1.0.
400 * @param TypedDoublyLinkedList $list The list to fill with extension XML.
402 * @throws MalformedGPXException If the GPX file was invalid.
404 protected function readExtension(TypedDoublyLinkedList $list)
407 'extensions' => function ($list) {
408 $list[] = $this->xml->readOuterXml();
412 $this->readStruct($struct, $list);
416 * Read a data structure from XML.
418 * This is a generalized function for parsing XML data structures and
419 * populating values based on data retrieved. The main control element is the
420 * `$struct` array which holds a list of callable functions which are used to
421 * modify the supplied object. The structure of the array is:
424 * 'extensions' => function($object, &$state) {...},
426 * 'nodename' => function($object, &$state) {...}
430 * The following keys are defined:
432 * * `extensions` - Call the supplied anonymous function if any XML element
433 * is encountered in a namespace other than the document
435 * * `elements` - Call the supplied anonymous function if an XML element
436 * is encountered in the document namespace with a matching
439 * The anonymous function parameters are as follows:
441 * * `$object` - The object to be populated (as passed to this function)
442 * * `$state` - An array that can be used to store data temporarily while
443 * the structure is being written. This is useful if a value
444 * that is being read is split across several XML elements.
446 * @param array $struct a data structure as defined above.
447 * @param object $object The object to populate with data.
449 * @throws MalformedGPXException If the GPX file was invalid or not supported.
451 protected function readStruct($struct, $object)
454 if ($xml->isEmptyElement) {
458 $element = $xml->localName;
463 $xml->nodeType == XMLReader::END_ELEMENT
464 && $xml->namespaceURI == $this->ns
465 && $xml->localName == $element
467 if ($xml->nodeType != XMLReader::ELEMENT) {
472 $xml->namespaceURI == $this->ns
473 && isset($struct['elements'][$xml->localName])
475 if (is_callable($struct['elements'][$xml->localName])) {
476 $struct['elements'][$xml->localName]($object, $state);
483 $xml->namespaceURI != $this->ns
484 && isset($struct['extensions'])
485 && is_callable($struct['extensions'])
487 $struct['extensions']($object, $state);
490 throw new MalformedGPXException(
492 'Unknown element "%s" in element "%s"',
501 * Read an `xsd:string` type from XML.
503 * @return string The text string.
504 * @throws MalformedGPXException If the element has non-text children.
506 protected function readXSDString()
510 if ($xml->nodeType != XMLReader::ELEMENT || $xml->isEmptyElement)
512 $element = $xml->name;
513 while ($xml->read()) {
514 switch ($xml->nodeType) {
515 case XMLReader::TEXT:
516 case XMLReader::CDATA:
517 $result .= $xml->value;
519 case XMLReader::END_ELEMENT:
523 throw new MalformedGPXException(
524 sprintf('Element "%s" contains non-text data.', $element)
533 * Convert a string to DateTime.
535 * @param string $timestamp The timestamp to convert.
536 * @return DateTime The parsed DateTime.
537 * @throws MalformedGPXException If the timestamp could not be parsed.
539 protected function string2DateTime(string $timestamp)
542 $result = new DateTime($timestamp, new DateTimeZone('Z'));
543 } catch (Exception $e) {
544 throw new MalformedGPXException(
545 sprintf('Unknown datetime format "%s"', $timestamp)