]> git.street.me.uk Git - andy/gpx.git/blob - src/libgpx/gpxreader.php
Initial commit
[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 \Exception;
30 use \InvalidArgumentException;
31 use \RuntimeException;
32
33 /**
34  * Read GPX files.
35  *
36  * @author Andy Street <andy@street.me.uk>
37  */
38 class GPXReader
39 {
40
41   /**
42    * The namespace of the XML currently being parsed.
43    *
44    * @var string
45    */
46   protected $ns;
47
48   /**
49    * The GPX version of the XML currently being parsed.
50    *
51    * @var string
52    */
53   protected $version;
54
55   /**
56    * The reader currently reading the XML.
57    *
58    * @var XMLReader
59    */
60   protected $xml;
61
62   /**
63    * Read GPX from a string.
64    *
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.
69    */
70   public function readString(string $xml)
71   {
72     try {
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.');
76       }
77       return $this->read();
78     } finally {
79       $this->xml = null;
80     }
81   }
82
83   /**
84    * Read GPX from a file.
85    *
86    * This function is an alias of `GPXReader::readURI()`.
87    *
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.
92    */
93   public function readFile(string $filename)
94   {
95     return $this->readURI($filename);
96   }
97
98   /**
99    * Read GPX from a URI.
100    *
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.
105    */
106   public function readURI(string $uri)
107   {
108     try {
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));
112       }
113       return $this->read();
114     } finally {
115       $this->xml = null;
116     }
117   }
118
119   /**
120    * Read GPX data from XML.
121    *
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.
125    */
126   protected function read()
127   {
128     $xml = $this->xml;
129     try {
130       // Fast forward to the root element.
131       while ($xml->nodeType !== XMLReader::ELEMENT) {
132         $xml->read();
133       }
134
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;
140           break;
141         case libgpx::NAMESPACE_GPX_1_1:
142           $this->version = '1.1';
143           $this->ns = libgpx::NAMESPACE_GPX_1_1;
144           break;
145         default:
146           throw new MalformedGPXException('Unknown or unsupported file format.');
147       }
148
149       // Read GPX element.
150       if ($xml->localName == 'gpx')
151         return $this->readGPX();
152       else
153         throw new MalformedGPXException('Root element must be "gpx".');
154
155     } finally {
156       $this->version = null;
157       $this->ns = null;
158     }
159   }
160
161   /**
162    * Read a gpx type.
163    *
164    * @return GPX The GPX data.
165    * @throws MalformedGPXException If the GPX file was invalid or not supported.
166    */
167   protected function readGPX()
168   {
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.'
173       );
174
175     $result = new GPX();
176     $result->setCreator($this->xml->getAttribute('creator'));
177
178     $struct = [
179       'elements' => []
180     ];
181     if ($this->version == '1.1') {
182       $struct['elements']['metadata'] = function ($gpx) {
183         $this->readMetadata($gpx);
184       };
185     } else {
186       $struct['elements']['name'] = function ($gpx) {
187         $gpx->setName($this->readXSDString());
188       };
189       $struct['elements']['desc'] = function ($gpx) {
190         $gpx->setDesc($this->readXSDString());
191       };
192       $struct['elements']['author'] = function ($gpx) {
193         $author = $gpx->getAuthor();
194         if ($author === null) {
195           $author = new Person();
196           $gpx->setAuthor($author);
197         }
198         $author->setName($this->readXSDString());
199       };
200       $struct['elements']['email'] = function ($gpx) {
201         $author = $gpx->getAuthor();
202         if ($author === null) {
203           $author = new Person();
204           $gpx->setAuthor($author);
205         }
206         $author->setEmail($this->readXSDString());
207       };
208       $struct['elements']['url'] = function ($gpx, &$state) {
209         $href = $this->readXSDString();
210         $links = $gpx->getLinks();
211         if (isset($links[0])) {
212           $links[0]->setHref($href);
213         } else {
214           $link = new Link($href);
215           if (isset($state['urlname'])) {
216             $link->setText($state['urlname']);
217             unset($state['urlname']);
218           }
219           $gpx->addLink($link);
220         }
221       };
222       $struct['elements']['urlname'] = function ($gpx, &$state) {
223         $text = $this->readXSDString();
224         $links = $gpx->getLinks();
225         if (isset($links[0])) {
226           $links[0]->setText($text);
227         } else {
228           $state['urlname'] = $text;
229         }
230       };
231       $struct['elements']['time'] = function ($gpx) {
232         $gpx->setTime($this->string2DateTime($this->readXSDString()));
233       };
234       $struct['elements']['keywords'] = function ($gpx) {
235         $keywords = explode(',', $this->readXSDString());
236         foreach ($keywords as $keyword)
237           $gpx->addKeyword(trim($keyword));
238       };
239       $struct['elements']['bounds'] = false;
240     }
241
242     $this->readStruct($struct, $result);
243     return $result;
244   }
245
246   /**
247    * Read a metadata type.
248    *
249    * Note: This is GPX 1.1 only as GPX 1.0 does not have a metadata type.
250    *
251    * @param GPX $gpx The gpx object to populate.
252    * @return void
253    * @throws MalformedGPXException If the GPX file was invalid or not supported.
254    */
255   protected function readMetadata(GPX $gpx)
256   {
257     $struct = [
258       'elements' => [
259         'name' => function ($gpx) {
260           $gpx->setName($this->readXSDString());
261         },
262         'desc' => function ($gpx) {
263           $gpx->setDesc($this->readXSDString());
264         },
265         'author' => function ($gpx) {
266           $gpx->setAuthor($this->readPerson());
267         },
268         'copyright' => function ($gpx) {
269           $gpx->setCopyright($this->readCopyright());
270         },
271         'link' => function ($gpx) {
272           $gpx->addLink($this->readLink());
273         },
274         'time' => function ($gpx) {
275           $gpx->setTime($this->string2DateTime($this->readXSDString()));
276         },
277         'keywords' => function ($gpx) {
278           $keywords = explode(',', $this->readXSDString());
279           foreach ($keywords as $keyword)
280             $gpx->addKeyword(trim($keyword));
281         },
282         'bounds' => false
283       ]
284     ];
285     $this->readStruct($struct, $gpx);
286   }
287
288   /**
289    * Read a person type.
290    *
291    * @return Person The person data.
292    * @throws MalformedGPXException If the GPX file was invalid or not supported.
293    */
294   protected function readPerson()
295   {
296     $struct = [
297       'elements' => [
298         'name' => function ($person) {
299           $person->setName($this->readXSDString());
300         },
301         'email' => function ($person) {
302           $id = $this->xml->getAttribute('id');
303           if ($id === null)
304             throw new MalformedGPXException(
305               'Missing required attribute "id" in "email"'
306             );
307           $domain = $this->xml->getAttribute('domain');
308           if ($domain === null)
309             throw new MalformedGPXException(
310               'Missing required attribute "domain" in "email"'
311             );
312           $person->setEmail($id . '@' . $domain);
313           $this->xml->read();
314         },
315         'link' => function ($person) {
316           $person->setLink($this->readLink());
317         }
318       ]
319     ];
320     $person = new Person();
321     $this->readStruct($struct, $person);
322     return $person;
323   }
324
325   /**
326    * Read a link type.
327    *
328    * @return Link The link data.
329    * @throws MalformedGPXException If the GPX file was invalid or not supported.
330    */
331   protected function readLink()
332   {
333     $href = $this->xml->getAttribute('href');
334     if ($href === null)
335       throw new MalformedGPXException(
336         'Missing required attribute "href" in "link"'
337       );
338     $struct = [
339       'elements' => [
340         'text' => function ($link) {
341           $link->setText($this->readXSDString());
342         },
343         'type' => function ($link) {
344           $link->setType($this->readXSDString());
345         }
346       ]
347     ];
348     $link = new Link($href);
349     $this->readStruct($struct, $link);
350     return $link;
351   }
352
353   /**
354    * Read a copyright type.
355    *
356    * @return Copyright The copyright data.
357    * @throws MalformedGPXException If the GPX file was invalid or not supported.
358    */
359   protected function readCopyright()
360   {
361     $author = $this->xml->getAttribute('author');
362     if ($author === null)
363       throw new MalformedGPXException(
364         'Missing required attribute "author" in "copyright"'
365       );
366     $struct = [
367       'elements' => [
368         'year' => function ($copyright) {
369           try {
370             $year = $this->readXSDString();
371             $copyright->setYear($year);
372           } catch (InvalidArgumentException $e) {
373             throw new MalformedGPXException(
374               sprintf('"%s" is not a valid year', $year)
375             );
376           }
377         },
378         'license' => function ($copyright) {
379           $copyright->setLicense($this->readXSDString());
380         }
381       ]
382     ];
383     $copyright = new Copyright($author);
384     $this->readStruct($struct, $copyright);
385     return $copyright;
386   }
387
388   /**
389    * Read a data structure from XML.
390    *
391    * This is a generalized function for parsing XML data structures and
392    * populating values based on data retrieved. The main control element is the
393    * `$struct` array which holds a list of callable functions which are used to
394    * modify the supplied object. The structure of the array is:
395    *
396    *     [
397    *       'elements' => [
398    *         'tagname' => function($object, &$state) {...}
399    *       ]
400    *     ]
401    *
402    * The anonymous function parameters are as follows:
403    *
404    *   * `$object` - The object to be populated (as passed to this function)
405    *   * `$state`  - An array that can be used to store data temporarily while
406    *                 the structure is being written. This is useful if a value
407    *                 that is being read is split across several XML elements.
408    *
409    * @param array $struct a data structure as defined above.
410    * @param object $object The object to populate with data.
411    * @return void
412    * @throws MalformedGPXException If the GPX file was invalid or not supported.
413    */
414   protected function readStruct($struct, $object)
415   {
416     $xml = $this->xml;
417     if ($xml->isEmptyElement) {
418       $xml->read();
419       return;
420     }
421     $element = $xml->localName;
422     $xml->read();
423     $state = [];
424     while (true) {
425       if (
426         $xml->nodeType == XMLReader::END_ELEMENT
427         && $xml->namespaceURI == $this->ns
428         && $xml->localName == $element
429       ) return;
430       if ($xml->nodeType != XMLReader::ELEMENT) {
431         $xml->read();
432         continue;
433       }
434       if (
435         $xml->namespaceURI == $this->ns
436         && isset($struct['elements'][$xml->localName])
437       ) {
438         if (is_callable($struct['elements'][$xml->localName])) {
439           $struct['elements'][$xml->localName]($object, $state);
440         } else {
441           $xml->read();
442         }
443         continue;
444       }
445       throw new MalformedGPXException(
446         sprintf(
447           'Unknown element "%s" in element "%s"',
448           $xml->localName,
449           $element
450         )
451       );
452     }
453   }
454
455   /**
456    * Read an `xsd:string` type from XML.
457    *
458    * @return string The text string.
459    * @throws MalformedGPXException If the element has non-text children.
460    */
461   protected function readXSDString()
462   {
463     $xml = $this->xml;
464     $result = '';
465     if ($xml->nodeType != XMLReader::ELEMENT || $xml->isEmptyElement)
466       return $result;
467     $element = $xml->name;
468     while ($xml->read()) {
469       switch ($xml->nodeType) {
470         case XMLReader::TEXT:
471         case XMLReader::CDATA:
472           $result .= $xml->value;
473           break;
474         case XMLReader::END_ELEMENT:
475           $xml->read();
476           break 2;
477         default:
478           throw new MalformedGPXException(
479             sprintf('Element "%s" contains non-text data.', $element)
480           );
481       }
482
483     }
484     return $result;
485   }
486
487   /**
488    * Convert a string to DateTime.
489    *
490    * @param string $timestamp The timestamp to convert.
491    * @return DateTime The parsed DateTime.
492    * @throws MalformedGPXException If the timestamp could not be parsed.
493    */
494   protected function string2DateTime(string $timestamp)
495   {
496     try {
497       $result = new DateTime($timestamp, new DateTimeZone('Z'));
498     } catch (Exception $e) {
499       throw new MalformedGPXException(
500         sprintf('Unknown datetime format "%s"', $timestamp)
501       );
502     }
503     return $result;
504   }
505
506 }