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,
31 use \InvalidArgumentException;
32 use \RuntimeException;
37 * @author Andy Street <andy@street.me.uk>
43 * The namespace of the XML currently being parsed.
50 * The GPX version of the XML currently being parsed.
57 * The reader currently reading the XML.
64 * Read GPX from a string.
66 * @param string $xml The GPX XML data.
67 * @return GPX The GPX data.
68 * @throws RuntimeException If the XML could not be read.
69 * @throws MalformedGPXException If the GPX file is invalid.
71 public function readString(string $xml)
74 $this->xml = new XMLReader();
75 if (!$this->xml->XML($xml, null, LIBXML_NOERROR | LIBXML_NOWARNING)) {
76 throw new RuntimeException('Unable to read XML from string.');
85 * Read GPX from a file.
87 * This function is an alias of `GPXReader::readURI()`.
89 * @param string $filename The name of the file containing GPX XML data.
90 * @return GPX The GPX data.
91 * @throws RuntimeException If the XML could not be read.
92 * @throws MalformedGPXException If the GPX file is invalid.
94 public function readFile(string $filename)
96 return $this->readURI($filename);
100 * Read GPX from a URI.
102 * @param string $uri The location of the file containing GPX XML data.
103 * @return GPX The GPX data.
104 * @throws RuntimeException If the XML could not be read.
105 * @throws MalformedGPXException If the GPX file is invalid.
107 public function readURI(string $uri)
110 $this->xml = new XMLReader();
111 if (!$this->xml->open($uri, null, LIBXML_NOERROR | LIBXML_NOWARNING)) {
112 throw new RuntimeException(sprintf('Unable to read XML from: %s', $uri));
114 return $this->read();
121 * Read GPX data from XML.
123 * @param XMLReader $xml Where to read the GPX data from.
124 * @return GPX The data that was read.
125 * @throws MalformedGPXException If the GPX file was invalid or not supported.
127 protected function read()
131 // Fast forward to the root element.
132 while ($xml->nodeType !== XMLReader::ELEMENT) {
136 // Detect file version
137 switch ($xml->namespaceURI) {
138 case libgpx::NAMESPACE_GPX_1_0:
139 $this->version = '1.0';
140 $this->ns = libgpx::NAMESPACE_GPX_1_0;
142 case libgpx::NAMESPACE_GPX_1_1:
143 $this->version = '1.1';
144 $this->ns = libgpx::NAMESPACE_GPX_1_1;
147 throw new MalformedGPXException('Unknown or unsupported file format.');
151 if ($xml->localName == 'gpx')
152 return $this->readGPX();
154 throw new MalformedGPXException('Root element must be "gpx".');
157 $this->version = null;
165 * @return GPX The GPX data.
166 * @throws MalformedGPXException If the GPX file was invalid or not supported.
168 protected function readGPX()
170 // Sanity check - ensure version attribute and schema declaration match.
171 if ($this->xml->getAttribute('version') != $this->version)
172 throw new MalformedGPXException(
173 'GPX version attribute does not match namespace declaration.'
177 $result->setCreator($this->xml->getAttribute('creator'));
181 'wpt' => function ($gpx) {
182 $gpx->getWaypoints()[] = $this->readWpt();
184 'rte' => function ($gpx) {
185 $gpx->getRoutes()[] = $this->readRte();
189 if ($this->version == '1.1') {
190 $struct['elements']['metadata'] = function ($gpx) {
191 $this->readMetadata($gpx);
194 $struct['elements']['name'] = function ($gpx) {
195 $gpx->setName($this->readXSDString());
197 $struct['elements']['desc'] = function ($gpx) {
198 $gpx->setDesc($this->readXSDString());
200 $struct['elements']['author'] = function ($gpx) {
201 $author = $gpx->getAuthor();
202 if ($author === null) {
203 $author = new Person();
204 $gpx->setAuthor($author);
206 $author->setName($this->readXSDString());
208 $struct['elements']['email'] = function ($gpx) {
209 $author = $gpx->getAuthor();
210 if ($author === null) {
211 $author = new Person();
212 $gpx->setAuthor($author);
214 $author->setEmail($this->readXSDString());
216 $struct['elements']['url'] = function ($gpx, &$state) {
217 $href = $this->readXSDString();
218 $links = $gpx->getLinks();
219 if ($links->isEmpty()) {
220 $link = new Link($href);
221 if (isset($state['urlname'])) {
222 $link->setText($state['urlname']);
223 unset($state['urlname']);
227 $links->bottom()->setHref($href);
230 $struct['elements']['urlname'] = function ($gpx, &$state) {
231 $text = $this->readXSDString();
232 $links = $gpx->getLinks();
233 if ($links->isEmpty()) {
234 $state['urlname'] = $text;
236 $links->bottom()->setText($text);
239 $struct['elements']['time'] = function ($gpx) {
240 $gpx->setTime($this->string2DateTime($this->readXSDString()));
242 $struct['elements']['keywords'] = function ($gpx) {
243 $keywords = $gpx->getKeywords();
244 $words = explode(',', $this->readXSDString());
245 foreach ($words as $word)
246 $keywords[] = trim($word);
248 $struct['elements']['bounds'] = false;
251 $this->readStruct($struct, $result);
256 * Read a metadata type.
258 * Note: This is GPX 1.1 only as GPX 1.0 does not have a metadata type.
260 * @param GPX $gpx The gpx object to populate.
262 * @throws MalformedGPXException If the GPX file was invalid or not supported.
264 protected function readMetadata(GPX $gpx)
268 'name' => function ($gpx) {
269 $gpx->setName($this->readXSDString());
271 'desc' => function ($gpx) {
272 $gpx->setDesc($this->readXSDString());
274 'author' => function ($gpx) {
275 $gpx->setAuthor($this->readPerson());
277 'copyright' => function ($gpx) {
278 $gpx->setCopyright($this->readCopyright());
280 'link' => function ($gpx) {
281 $links = $gpx->getLinks();
282 $links[] = $this->readLink();
284 'time' => function ($gpx) {
285 $gpx->setTime($this->string2DateTime($this->readXSDString()));
287 'keywords' => function ($gpx) {
288 $keywords = $gpx->getKeywords();
289 $words = explode(',', $this->readXSDString());
290 foreach ($words as $word)
291 $keywords[] = trim($word);
293 'bounds' => false, // Not required as it is calculated internally.
294 'extensions' => function ($gpx) {
295 $this->readExtension($gpx->getMetadataExtensions());
299 $this->readStruct($struct, $gpx);
303 * Read a person type.
305 * @return Person The person data.
306 * @throws MalformedGPXException If the GPX file was invalid or not supported.
308 protected function readPerson()
312 'name' => function ($person) {
313 $person->setName($this->readXSDString());
315 'email' => function ($person) {
316 $id = $this->xml->getAttribute('id');
318 throw new MalformedGPXException(
319 'Missing required attribute "id" in "email"'
321 $domain = $this->xml->getAttribute('domain');
322 if ($domain === null)
323 throw new MalformedGPXException(
324 'Missing required attribute "domain" in "email"'
326 $person->setEmail($id . '@' . $domain);
329 'link' => function ($person) {
330 $person->setLink($this->readLink());
334 $person = new Person();
335 $this->readStruct($struct, $person);
342 * @return Link The link data.
343 * @throws MalformedGPXException If the GPX file was invalid or not supported.
345 protected function readLink()
347 $href = $this->xml->getAttribute('href');
349 throw new MalformedGPXException(
350 'Missing required attribute "href" in "link"'
354 'text' => function ($link) {
355 $link->setText($this->readXSDString());
357 'type' => function ($link) {
358 $link->setType($this->readXSDString());
362 $link = new Link($href);
363 $this->readStruct($struct, $link);
368 * Read a copyright type.
370 * @return Copyright The copyright data.
371 * @throws MalformedGPXException If the GPX file was invalid or not supported.
373 protected function readCopyright()
375 $author = $this->xml->getAttribute('author');
376 if ($author === null)
377 throw new MalformedGPXException(
378 'Missing required attribute "author" in "copyright"'
382 'year' => function ($copyright) {
384 $year = $this->readXSDString();
385 $copyright->setYear($year);
386 } catch (InvalidArgumentException $e) {
387 throw new MalformedGPXException(
388 sprintf('"%s" is not a valid year', $year)
392 'license' => function ($copyright) {
393 $copyright->setLicense($this->readXSDString());
397 $copyright = new Copyright($author);
398 $this->readStruct($struct, $copyright);
403 * Read an extensions type.
405 * This is suitable for GPX 1.1 only as private elements are mixed into other
406 * elements in GPX 1.0.
408 * @param TypedDoublyLinkedList $list The list to fill with extension XML.
410 * @throws MalformedGPXException If the GPX file was invalid.
412 protected function readExtension(TypedDoublyLinkedList $list)
415 'extensions' => function ($list) {
416 $list[] = $this->xml->readOuterXml();
420 $this->readStruct($struct, $list);
426 * @see https://www.topografix.com/GPX/1/1/#type_wptType
429 * @throws MalformedGPXException If the GPX file was invalid or not supported.
431 protected function readWpt()
435 $this->string2float($this->xml->getAttribute('lat')),
436 $this->string2float($this->xml->getAttribute('lon'))
440 'ele' => function ($point) {
441 $point->setEle($this->string2float($this->readXSDString()));
443 'time' => function ($point) {
444 $point->setTime($this->string2DateTime($this->readXSDString()));
446 'magvar' => function ($point) {
447 $point->setMagvar($this->string2float($this->readXSDString()));
449 'geoidheight' => function ($point) {
450 $point->setGeoidHeight($this->string2float($this->readXSDString()));
452 'name' => function ($point) {
453 $point->setName($this->readXSDString());
455 'cmt' => function ($point) {
456 $point->setComment($this->readXSDString());
458 'desc' => function ($point) {
459 $point->setDescription($this->readXSDString());
461 'src' => function ($point) {
462 $point->setSource($this->readXSDString());
464 'sym' => function ($point) {
465 $point->setSymbol($this->readXSDString());
467 'type' => function ($point) {
468 $point->setType($this->readXSDString());
470 'fix' => function ($point) {
471 $point->setFix($this->readXSDString());
473 'sat' => function ($point) {
474 $point->setSatellites($this->string2int($this->readXSDString()));
476 'hdop' => function ($point) {
477 $point->setHdop($this->string2float($this->readXSDString()));
479 'vdop' => function ($point) {
480 $point->setVdop($this->string2float($this->readXSDString()));
482 'pdop' => function ($point) {
483 $point->setPdop($this->string2float($this->readXSDString()));
485 'ageofdgpsdata' => function ($point) {
486 $point->setAgeOfDGPSData($this->string2float($this->readXSDString()));
488 'dgpsid' => function ($point) {
489 $point->setDGPSId($this->string2int($this->readXSDString()));
493 if ($this->version == '1.1') {
494 $struct['elements']['link'] = function ($point) {
495 $point->getLinks()[] = $this->readLink();
497 $struct['elements']['extensions'] = function ($point) {
498 $this->readExtension($point->getExtensions());
501 $struct['elements']['url'] = function ($point, &$state) {
502 $href = $this->readXSDString();
503 $links = $point->getLinks();
504 if ($links->isEmpty()) {
505 $link = new Link($href);
506 if (isset($state['urlname'])) {
507 $link->setText($state['urlname']);
508 unset($state['urlname']);
512 $links->bottom()->setHref($href);
515 $struct['elements']['urlname'] = function ($point, &$state) {
516 $text = $this->readXSDString();
517 $links = $point->getLinks();
518 if ($links->isEmpty()) {
519 $state['urlname'] = $text;
521 $links->bottom()->setText($text);
524 $struct['extensions'] = function ($point) {
525 $point->getExtensions()[] = $this->xml->readOuterXML();
529 $this->readStruct($struct, $result);
530 } catch (DomainException $e) {
531 throw new MalformedGPXException(
532 $e->getMessage(), $e->getCode(), $e
541 * @see https://www.topografix.com/GPX/1/1/#type_rteType
544 * @throws MalformedGPXException If the GPX file was invalid or not supported.
546 protected function readRte()
549 $result = new Route();
552 'name' => function ($route) {
553 $route->setName($this->readXSDString());
555 'cmt' => function ($route) {
556 $route->setComment($this->readXSDString());
558 'desc' => function ($route) {
559 $route->setDescription($this->readXSDString());
561 'src' => function ($route) {
562 $route->setSource($this->readXSDString());
564 'number' => function ($route) {
565 $route->setNumber($this->string2int($this->readXSDString()));
567 'rtept' => function ($route) {
568 $route->getPoints()[] = $this->readWpt();
572 if ($this->version == '1.1') {
573 $struct['elements']['link'] = function ($route) {
574 $route->getLinks()[] = $this->readLink();
576 $struct['elements']['type'] = function ($route) {
577 $route->setType($this->readXSDString());
579 $struct['elements']['extensions'] = function ($route) {
580 $this->readExtension($route->getExtensions());
583 $struct['elements']['url'] = function ($route, &$state) {
584 $href = $this->readXSDString();
585 $links = $route->getLinks();
586 if ($links->isEmpty()) {
587 $link = new Link($href);
588 if (isset($state['urlname'])) {
589 $link->setText($state['urlname']);
590 unset($state['urlname']);
594 $links->bottom()->setHref($href);
597 $struct['elements']['urlname'] = function ($route, &$state) {
598 $text = $this->readXSDString();
599 $links = $route->getLinks();
600 if ($links->isEmpty()) {
601 $state['urlname'] = $text;
603 $links->bottom()->setText($text);
606 $struct['extensions'] = function ($route) {
607 $route->getExtensions()[] = $this->xml->readOuterXML();
611 $this->readStruct($struct, $result);
612 } catch (DomainException $e) {
613 throw new MalformedGPXException(
614 $e->getMessage(), $e->getCode(), $e
621 * Read a data structure from XML.
623 * This is a generalized function for parsing XML data structures and
624 * populating values based on data retrieved. The main control element is the
625 * `$struct` array which holds a list of callable functions which are used to
626 * modify the supplied object. The structure of the array is:
629 * 'extensions' => function($object, &$state) {...},
631 * 'nodename' => function($object, &$state) {...}
635 * The following keys are defined:
637 * * `extensions` - Call the supplied anonymous function if any XML element
638 * is encountered in a namespace other than the document
640 * * `elements` - Call the supplied anonymous function if an XML element
641 * is encountered in the document namespace with a matching
644 * The anonymous function parameters are as follows:
646 * * `$object` - The object to be populated (as passed to this function)
647 * * `$state` - An array that can be used to store data temporarily while
648 * the structure is being written. This is useful if a value
649 * that is being read is split across several XML elements.
651 * @param array $struct a data structure as defined above.
652 * @param object $object The object to populate with data.
654 * @throws MalformedGPXException If the GPX file was invalid or not supported.
656 protected function readStruct($struct, $object)
659 if ($xml->isEmptyElement) {
663 $element = $xml->localName;
668 $xml->nodeType == XMLReader::END_ELEMENT
669 && $xml->namespaceURI == $this->ns
670 && $xml->localName == $element
672 if ($xml->nodeType != XMLReader::ELEMENT) {
677 $xml->namespaceURI == $this->ns
678 && isset($struct['elements'][$xml->localName])
680 if (is_callable($struct['elements'][$xml->localName])) {
681 $struct['elements'][$xml->localName]($object, $state);
688 $xml->namespaceURI != $this->ns
689 && isset($struct['extensions'])
690 && is_callable($struct['extensions'])
692 $struct['extensions']($object, $state);
695 throw new MalformedGPXException(
697 'Unknown element "%s" in element "%s"',
706 * Read an `xsd:string` type from XML.
708 * @return string The text string.
709 * @throws MalformedGPXException If the element has non-text children.
711 protected function readXSDString()
715 if ($xml->nodeType != XMLReader::ELEMENT || $xml->isEmptyElement)
717 $element = $xml->name;
718 while ($xml->read()) {
719 switch ($xml->nodeType) {
720 case XMLReader::TEXT:
721 case XMLReader::CDATA:
722 $result .= $xml->value;
724 case XMLReader::END_ELEMENT:
728 throw new MalformedGPXException(
729 sprintf('Element "%s" contains non-text data.', $element)
738 * Convert a string to DateTime.
740 * @param string $timestamp The timestamp to convert.
741 * @return DateTime The parsed DateTime.
742 * @throws MalformedGPXException If the timestamp could not be parsed.
744 protected function string2DateTime(string $timestamp)
747 $result = new DateTime($timestamp, new DateTimeZone('Z'));
748 } catch (Exception $e) {
749 throw new MalformedGPXException(
750 sprintf('Unknown datetime format "%s"', $timestamp)
757 * Convert a string to a float.
759 * @param string $value The string value to convert.
761 * @throws MalformedGPXException If the value is not numeric.
763 protected function string2float(string $value)
765 if (!is_numeric($value))
766 throw new MalformedGPXException(
767 sprintf('Expected decimal value but got "%s"', $value)
769 return floatval($value);
773 * Convert a string to a int.
775 * @param string $value The string value to convert.
777 * @throws MalformedGPXException If the value is not numeric.
779 protected function string2int(string $value)
781 if (!is_numeric($value))
782 throw new MalformedGPXException(
783 sprintf('Expected decimal value but got "%s"', $value)
785 return intval($value);