]> git.street.me.uk Git - andy/gpx.git/blob - src/libgpx/gpxreader.php
077f85e9f290debedd121b01a1e6a4753d23f866
[andy/gpx.git] / src / libgpx / gpxreader.php
1 <?php
2 /**
3  * gpxreader.php
4  *
5  * Copyright 2018 Andy Street <andy@street.me.uk>
6  *
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.
11  *
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.
16  *
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,
20  * MA 02110-1301, USA.
21  *
22  *
23  */
24
25 namespace libgpx;
26
27 use \DateTime;
28 use \DateTimeZone;
29 use \DomainException;
30 use \Exception;
31 use \InvalidArgumentException;
32 use \RuntimeException;
33
34 /**
35  * Read GPX files.
36  *
37  * @author Andy Street <andy@street.me.uk>
38  */
39 class GPXReader
40 {
41
42   /**
43    * The namespace of the XML currently being parsed.
44    *
45    * @var string
46    */
47   protected $ns;
48
49   /**
50    * The GPX version of the XML currently being parsed.
51    *
52    * @var string
53    */
54   protected $version;
55
56   /**
57    * The reader currently reading the XML.
58    *
59    * @var XMLReader
60    */
61   protected $xml;
62
63   /**
64    * Read GPX from a string.
65    *
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.
70    */
71   public function readString(string $xml)
72   {
73     try {
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.');
77       }
78       return $this->read();
79     } finally {
80       $this->xml = null;
81     }
82   }
83
84   /**
85    * Read GPX from a file.
86    *
87    * This function is an alias of `GPXReader::readURI()`.
88    *
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.
93    */
94   public function readFile(string $filename)
95   {
96     return $this->readURI($filename);
97   }
98
99   /**
100    * Read GPX from a URI.
101    *
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.
106    */
107   public function readURI(string $uri)
108   {
109     try {
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));
113       }
114       return $this->read();
115     } finally {
116       $this->xml = null;
117     }
118   }
119
120   /**
121    * Read GPX data from XML.
122    *
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.
126    */
127   protected function read()
128   {
129     $xml = $this->xml;
130     try {
131       // Fast forward to the root element.
132       while ($xml->nodeType !== XMLReader::ELEMENT) {
133         $xml->read();
134       }
135
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;
141           break;
142         case libgpx::NAMESPACE_GPX_1_1:
143           $this->version = '1.1';
144           $this->ns = libgpx::NAMESPACE_GPX_1_1;
145           break;
146         default:
147           throw new MalformedGPXException('Unknown or unsupported file format.');
148       }
149
150       // Read GPX element.
151       if ($xml->localName == 'gpx')
152         return $this->readGPX();
153       else
154         throw new MalformedGPXException('Root element must be "gpx".');
155
156     } finally {
157       $this->version = null;
158       $this->ns = null;
159     }
160   }
161
162   /**
163    * Read a gpx type.
164    *
165    * @return GPX The GPX data.
166    * @throws MalformedGPXException If the GPX file was invalid or not supported.
167    */
168   protected function readGPX()
169   {
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.'
174       );
175
176     $result = new GPX();
177     $result->setCreator($this->xml->getAttribute('creator'));
178
179     $struct = [
180       'elements' => [
181         'wpt' => function ($gpx) {
182           $gpx->getWaypoints()[] = $this->readWpt();
183         },
184         'rte' => function ($gpx) {
185           $gpx->getRoutes()[] = $this->readRte();
186         }
187       ]
188     ];
189     if ($this->version == '1.1') {
190       $struct['elements']['metadata'] = function ($gpx) {
191         $this->readMetadata($gpx);
192       };
193     } else {
194       $struct['elements']['name'] = function ($gpx) {
195         $gpx->setName($this->readXSDString());
196       };
197       $struct['elements']['desc'] = function ($gpx) {
198         $gpx->setDesc($this->readXSDString());
199       };
200       $struct['elements']['author'] = function ($gpx) {
201         $author = $gpx->getAuthor();
202         if ($author === null) {
203           $author = new Person();
204           $gpx->setAuthor($author);
205         }
206         $author->setName($this->readXSDString());
207       };
208       $struct['elements']['email'] = function ($gpx) {
209         $author = $gpx->getAuthor();
210         if ($author === null) {
211           $author = new Person();
212           $gpx->setAuthor($author);
213         }
214         $author->setEmail($this->readXSDString());
215       };
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']);
224           }
225           $links[] = $link;
226         } else {
227           $links->bottom()->setHref($href);
228         }
229       };
230       $struct['elements']['urlname'] = function ($gpx, &$state) {
231         $text = $this->readXSDString();
232         $links = $gpx->getLinks();
233         if ($links->isEmpty()) {
234           $state['urlname'] = $text;
235         } else {
236           $links->bottom()->setText($text);
237         }
238       };
239       $struct['elements']['time'] = function ($gpx) {
240         $gpx->setTime($this->string2DateTime($this->readXSDString()));
241       };
242       $struct['elements']['keywords'] = function ($gpx) {
243         $keywords = $gpx->getKeywords();
244         $words = explode(',', $this->readXSDString());
245         foreach ($words as $word)
246           $keywords[] = trim($word);
247       };
248       $struct['elements']['bounds'] = false;
249     }
250
251     $this->readStruct($struct, $result);
252     return $result;
253   }
254
255   /**
256    * Read a metadata type.
257    *
258    * Note: This is GPX 1.1 only as GPX 1.0 does not have a metadata type.
259    *
260    * @param GPX $gpx The gpx object to populate.
261    * @return void
262    * @throws MalformedGPXException If the GPX file was invalid or not supported.
263    */
264   protected function readMetadata(GPX $gpx)
265   {
266     $struct = [
267       'elements' => [
268         'name' => function ($gpx) {
269           $gpx->setName($this->readXSDString());
270         },
271         'desc' => function ($gpx) {
272           $gpx->setDesc($this->readXSDString());
273         },
274         'author' => function ($gpx) {
275           $gpx->setAuthor($this->readPerson());
276         },
277         'copyright' => function ($gpx) {
278           $gpx->setCopyright($this->readCopyright());
279         },
280         'link' => function ($gpx) {
281           $links = $gpx->getLinks();
282           $links[] = $this->readLink();
283         },
284         'time' => function ($gpx) {
285           $gpx->setTime($this->string2DateTime($this->readXSDString()));
286         },
287         'keywords' => function ($gpx) {
288           $keywords = $gpx->getKeywords();
289           $words = explode(',', $this->readXSDString());
290           foreach ($words as $word)
291             $keywords[] = trim($word);
292         },
293         'bounds' => false, // Not required as it is calculated internally.
294         'extensions' => function ($gpx) {
295           $this->readExtension($gpx->getMetadataExtensions());
296         }
297       ]
298     ];
299     $this->readStruct($struct, $gpx);
300   }
301
302   /**
303    * Read a person type.
304    *
305    * @return Person The person data.
306    * @throws MalformedGPXException If the GPX file was invalid or not supported.
307    */
308   protected function readPerson()
309   {
310     $struct = [
311       'elements' => [
312         'name' => function ($person) {
313           $person->setName($this->readXSDString());
314         },
315         'email' => function ($person) {
316           $id = $this->xml->getAttribute('id');
317           if ($id === null)
318             throw new MalformedGPXException(
319               'Missing required attribute "id" in "email"'
320             );
321           $domain = $this->xml->getAttribute('domain');
322           if ($domain === null)
323             throw new MalformedGPXException(
324               'Missing required attribute "domain" in "email"'
325             );
326           $person->setEmail($id . '@' . $domain);
327           $this->xml->read();
328         },
329         'link' => function ($person) {
330           $person->setLink($this->readLink());
331         }
332       ]
333     ];
334     $person = new Person();
335     $this->readStruct($struct, $person);
336     return $person;
337   }
338
339   /**
340    * Read a link type.
341    *
342    * @return Link The link data.
343    * @throws MalformedGPXException If the GPX file was invalid or not supported.
344    */
345   protected function readLink()
346   {
347     $href = $this->xml->getAttribute('href');
348     if ($href === null)
349       throw new MalformedGPXException(
350         'Missing required attribute "href" in "link"'
351       );
352     $struct = [
353       'elements' => [
354         'text' => function ($link) {
355           $link->setText($this->readXSDString());
356         },
357         'type' => function ($link) {
358           $link->setType($this->readXSDString());
359         }
360       ]
361     ];
362     $link = new Link($href);
363     $this->readStruct($struct, $link);
364     return $link;
365   }
366
367   /**
368    * Read a copyright type.
369    *
370    * @return Copyright The copyright data.
371    * @throws MalformedGPXException If the GPX file was invalid or not supported.
372    */
373   protected function readCopyright()
374   {
375     $author = $this->xml->getAttribute('author');
376     if ($author === null)
377       throw new MalformedGPXException(
378         'Missing required attribute "author" in "copyright"'
379       );
380     $struct = [
381       'elements' => [
382         'year' => function ($copyright) {
383           try {
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)
389             );
390           }
391         },
392         'license' => function ($copyright) {
393           $copyright->setLicense($this->readXSDString());
394         }
395       ]
396     ];
397     $copyright = new Copyright($author);
398     $this->readStruct($struct, $copyright);
399     return $copyright;
400   }
401
402   /**
403    * Read an extensions type.
404    *
405    * This is suitable for GPX 1.1 only as private elements are mixed into other
406    * elements in GPX 1.0.
407    *
408    * @param TypedDoublyLinkedList $list The list to fill with extension XML.
409    * @return void
410    * @throws MalformedGPXException If the GPX file was invalid.
411    */
412   protected function readExtension(TypedDoublyLinkedList $list)
413   {
414     $struct = [
415       'extensions' => function ($list) {
416         $list[] = $this->xml->readOuterXml();
417         $this->xml->next();
418       }
419     ];
420     $this->readStruct($struct, $list);
421   }
422
423   /**
424    * Read a wpt type.
425    *
426    * @see https://www.topografix.com/GPX/1/1/#type_wptType
427    *
428    * @return Point
429    * @throws MalformedGPXException If the GPX file was invalid or not supported.
430    */
431   protected function readWpt()
432   {
433     try {
434       $result = new Point(
435         $this->string2float($this->xml->getAttribute('lat')),
436         $this->string2float($this->xml->getAttribute('lon'))
437       );
438       $struct = [
439         'elements' => [
440           'ele' => function ($point) {
441             $point->setEle($this->string2float($this->readXSDString()));
442           },
443           'time' => function ($point) {
444             $point->setTime($this->string2DateTime($this->readXSDString()));
445           },
446           'magvar' => function ($point) {
447             $point->setMagvar($this->string2float($this->readXSDString()));
448           },
449           'geoidheight' => function ($point) {
450             $point->setGeoidHeight($this->string2float($this->readXSDString()));
451           },
452           'name' => function ($point) {
453             $point->setName($this->readXSDString());
454           },
455           'cmt' => function ($point) {
456             $point->setComment($this->readXSDString());
457           },
458           'desc' => function ($point) {
459             $point->setDescription($this->readXSDString());
460           },
461           'src' => function ($point) {
462             $point->setSource($this->readXSDString());
463           },
464           'sym' => function ($point) {
465             $point->setSymbol($this->readXSDString());
466           },
467           'type' => function ($point) {
468             $point->setType($this->readXSDString());
469           },
470           'fix' => function ($point) {
471             $point->setFix($this->readXSDString());
472           },
473           'sat' => function ($point) {
474             $point->setSatellites($this->string2int($this->readXSDString()));
475           },
476           'hdop' => function ($point) {
477             $point->setHdop($this->string2float($this->readXSDString()));
478           },
479           'vdop' => function ($point) {
480             $point->setVdop($this->string2float($this->readXSDString()));
481           },
482           'pdop' => function ($point) {
483             $point->setPdop($this->string2float($this->readXSDString()));
484           },
485           'ageofdgpsdata' => function ($point) {
486             $point->setAgeOfDGPSData($this->string2float($this->readXSDString()));
487           },
488           'dgpsid' => function ($point) {
489             $point->setDGPSId($this->string2int($this->readXSDString()));
490           }
491         ]
492       ];
493       if ($this->version == '1.1') {
494         $struct['elements']['link'] = function ($point) {
495           $point->getLinks()[] = $this->readLink();
496         };
497         $struct['elements']['extensions'] = function ($point) {
498           $this->readExtension($point->getExtensions());
499         };
500       } else {
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']);
509             }
510             $links[] = $link;
511           } else {
512             $links->bottom()->setHref($href);
513           }
514         };
515         $struct['elements']['urlname'] = function ($point, &$state) {
516           $text = $this->readXSDString();
517           $links = $point->getLinks();
518           if ($links->isEmpty()) {
519             $state['urlname'] = $text;
520           } else {
521             $links->bottom()->setText($text);
522           }
523         };
524         $struct['extensions'] = function ($point) {
525           $point->getExtensions()[] = $this->xml->readOuterXML();
526           $this->xml->next();
527         };
528       }
529       $this->readStruct($struct, $result);
530     } catch (DomainException $e) {
531       throw new MalformedGPXException(
532         $e->getMessage(), $e->getCode(), $e
533       );
534     }
535     return $result;
536   }
537
538   /**
539    * Read a rte type.
540    *
541    * @see https://www.topografix.com/GPX/1/1/#type_rteType
542    *
543    * @return Route
544    * @throws MalformedGPXException If the GPX file was invalid or not supported.
545    */
546   protected function readRte()
547   {
548     try {
549       $result = new Route();
550       $struct = [
551         'elements' => [
552           'name' => function ($route) {
553             $route->setName($this->readXSDString());
554           },
555           'cmt' => function ($route) {
556             $route->setComment($this->readXSDString());
557           },
558           'desc' => function ($route) {
559             $route->setDescription($this->readXSDString());
560           },
561           'src' => function ($route) {
562             $route->setSource($this->readXSDString());
563           },
564           'number' => function ($route) {
565             $route->setNumber($this->string2int($this->readXSDString()));
566           },
567           'rtept' => function ($route) {
568             $route->getPoints()[] = $this->readWpt();
569           }
570         ]
571       ];
572       if ($this->version == '1.1') {
573         $struct['elements']['link'] = function ($route) {
574           $route->getLinks()[] = $this->readLink();
575         };
576         $struct['elements']['type'] = function ($route) {
577           $route->setType($this->readXSDString());
578         };
579         $struct['elements']['extensions'] = function ($route) {
580           $this->readExtension($route->getExtensions());
581         };
582       } else {
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']);
591             }
592             $links[] = $link;
593           } else {
594             $links->bottom()->setHref($href);
595           }
596         };
597         $struct['elements']['urlname'] = function ($route, &$state) {
598           $text = $this->readXSDString();
599           $links = $route->getLinks();
600           if ($links->isEmpty()) {
601             $state['urlname'] = $text;
602           } else {
603             $links->bottom()->setText($text);
604           }
605         };
606         $struct['extensions'] = function ($route) {
607           $route->getExtensions()[] = $this->xml->readOuterXML();
608           $this->xml->next();
609         };
610       }
611       $this->readStruct($struct, $result);
612     } catch (DomainException $e) {
613       throw new MalformedGPXException(
614         $e->getMessage(), $e->getCode(), $e
615       );
616     }
617     return $result;
618   }
619
620   /**
621    * Read a data structure from XML.
622    *
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:
627    *
628    *     [
629    *       'extensions' => function($object, &$state) {...},
630    *       'elements' => [
631    *         'nodename' => function($object, &$state) {...}
632    *       ]
633    *     ]
634    *
635    * The following keys are defined:
636    *
637    *   * `extensions` - Call the supplied anonymous function if any XML element
638    *                    is encountered in a namespace other than the document
639    *                    namespace.
640    *   * `elements`   - Call the supplied anonymous function if an XML element
641    *                    is encountered in the document namespace with a matching
642    *                    node name.
643    *
644    * The anonymous function parameters are as follows:
645    *
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.
650    *
651    * @param array $struct a data structure as defined above.
652    * @param object $object The object to populate with data.
653    * @return void
654    * @throws MalformedGPXException If the GPX file was invalid or not supported.
655    */
656   protected function readStruct($struct, $object)
657   {
658     $xml = $this->xml;
659     if ($xml->isEmptyElement) {
660       $xml->read();
661       return;
662     }
663     $element = $xml->localName;
664     $xml->read();
665     $state = [];
666     while (true) {
667       if (
668         $xml->nodeType == XMLReader::END_ELEMENT
669         && $xml->namespaceURI == $this->ns
670         && $xml->localName == $element
671       ) return;
672       if ($xml->nodeType != XMLReader::ELEMENT) {
673         $xml->read();
674         continue;
675       }
676       if (
677         $xml->namespaceURI == $this->ns
678         && isset($struct['elements'][$xml->localName])
679       ) {
680         if (is_callable($struct['elements'][$xml->localName])) {
681           $struct['elements'][$xml->localName]($object, $state);
682         } else {
683           $xml->read();
684         }
685         continue;
686       }
687       if (
688         $xml->namespaceURI != $this->ns
689         && isset($struct['extensions'])
690         && is_callable($struct['extensions'])
691       ) {
692         $struct['extensions']($object, $state);
693         continue;
694       }
695       throw new MalformedGPXException(
696         sprintf(
697           'Unknown element "%s" in element "%s"',
698           $xml->localName,
699           $element
700         )
701       );
702     }
703   }
704
705   /**
706    * Read an `xsd:string` type from XML.
707    *
708    * @return string The text string.
709    * @throws MalformedGPXException If the element has non-text children.
710    */
711   protected function readXSDString()
712   {
713     $xml = $this->xml;
714     $result = '';
715     if ($xml->nodeType != XMLReader::ELEMENT || $xml->isEmptyElement)
716       return $result;
717     $element = $xml->name;
718     while ($xml->read()) {
719       switch ($xml->nodeType) {
720         case XMLReader::TEXT:
721         case XMLReader::CDATA:
722           $result .= $xml->value;
723           break;
724         case XMLReader::END_ELEMENT:
725           $xml->read();
726           break 2;
727         default:
728           throw new MalformedGPXException(
729             sprintf('Element "%s" contains non-text data.', $element)
730           );
731       }
732
733     }
734     return $result;
735   }
736
737   /**
738    * Convert a string to DateTime.
739    *
740    * @param string $timestamp The timestamp to convert.
741    * @return DateTime The parsed DateTime.
742    * @throws MalformedGPXException If the timestamp could not be parsed.
743    */
744   protected function string2DateTime(string $timestamp)
745   {
746     try {
747       $result = new DateTime($timestamp, new DateTimeZone('Z'));
748     } catch (Exception $e) {
749       throw new MalformedGPXException(
750         sprintf('Unknown datetime format "%s"', $timestamp)
751       );
752     }
753     return $result;
754   }
755
756   /**
757    * Convert a string to a float.
758    *
759    * @param string $value The string value to convert.
760    * @return float
761    * @throws MalformedGPXException If the value is not numeric.
762    */
763   protected function string2float(string $value)
764   {
765     if (!is_numeric($value))
766       throw new MalformedGPXException(
767         sprintf('Expected decimal value but got "%s"', $value)
768       );
769     return floatval($value);
770   }
771
772   /**
773    * Convert a string to a int.
774    *
775    * @param string $value The string value to convert.
776    * @return int
777    * @throws MalformedGPXException If the value is not numeric.
778    */
779   protected function string2int(string $value)
780   {
781     if (!is_numeric($value))
782       throw new MalformedGPXException(
783         sprintf('Expected decimal value but got "%s"', $value)
784       );
785     return intval($value);
786   }
787
788 }