]> git.street.me.uk Git - andy/gpx.git/blob - src/libgpx/gpxreader.php
Improve metadata
[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 ($links->isEmpty) {
212           $link = new Link($href);
213           if (isset($state['urlname'])) {
214             $link->setText($state['urlname']);
215             unset($state['urlname']);
216           }
217           $links[] = $link;
218         } else {
219           $links->bottom()->setHref($href);
220         }
221       };
222       $struct['elements']['urlname'] = function ($gpx, &$state) {
223         $text = $this->readXSDString();
224         $links = $gpx->getLinks();
225         if ($links->isEmpty) {
226           $state['urlname'] = $text;
227         } else {
228           $links->bottom()->setText($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 = $gpx->getKeywords();
236         $words = explode(',', $this->readXSDString());
237         foreach ($words as $word)
238           $keywords[] = trim($word);
239       };
240       $struct['elements']['bounds'] = false;
241     }
242
243     $this->readStruct($struct, $result);
244     return $result;
245   }
246
247   /**
248    * Read a metadata type.
249    *
250    * Note: This is GPX 1.1 only as GPX 1.0 does not have a metadata type.
251    *
252    * @param GPX $gpx The gpx object to populate.
253    * @return void
254    * @throws MalformedGPXException If the GPX file was invalid or not supported.
255    */
256   protected function readMetadata(GPX $gpx)
257   {
258     $struct = [
259       'elements' => [
260         'name' => function ($gpx) {
261           $gpx->setName($this->readXSDString());
262         },
263         'desc' => function ($gpx) {
264           $gpx->setDesc($this->readXSDString());
265         },
266         'author' => function ($gpx) {
267           $gpx->setAuthor($this->readPerson());
268         },
269         'copyright' => function ($gpx) {
270           $gpx->setCopyright($this->readCopyright());
271         },
272         'link' => function ($gpx) {
273           $links = $gpx->getLinks();
274           $links[] = $this->readLink();
275         },
276         'time' => function ($gpx) {
277           $gpx->setTime($this->string2DateTime($this->readXSDString()));
278         },
279         'keywords' => function ($gpx) {
280           $keywords = $gpx->getKeywords();
281           $words = explode(',', $this->readXSDString());
282           foreach ($words as $word)
283             $keywords[] = trim($word);
284         },
285         'bounds' => false, // Not required as it is calculated internally.
286         'extensions' => function ($gpx) {
287           $this->readExtension($gpx->getMetadataExtensions());
288         }
289       ]
290     ];
291     $this->readStruct($struct, $gpx);
292   }
293
294   /**
295    * Read a person type.
296    *
297    * @return Person The person data.
298    * @throws MalformedGPXException If the GPX file was invalid or not supported.
299    */
300   protected function readPerson()
301   {
302     $struct = [
303       'elements' => [
304         'name' => function ($person) {
305           $person->setName($this->readXSDString());
306         },
307         'email' => function ($person) {
308           $id = $this->xml->getAttribute('id');
309           if ($id === null)
310             throw new MalformedGPXException(
311               'Missing required attribute "id" in "email"'
312             );
313           $domain = $this->xml->getAttribute('domain');
314           if ($domain === null)
315             throw new MalformedGPXException(
316               'Missing required attribute "domain" in "email"'
317             );
318           $person->setEmail($id . '@' . $domain);
319           $this->xml->read();
320         },
321         'link' => function ($person) {
322           $person->setLink($this->readLink());
323         }
324       ]
325     ];
326     $person = new Person();
327     $this->readStruct($struct, $person);
328     return $person;
329   }
330
331   /**
332    * Read a link type.
333    *
334    * @return Link The link data.
335    * @throws MalformedGPXException If the GPX file was invalid or not supported.
336    */
337   protected function readLink()
338   {
339     $href = $this->xml->getAttribute('href');
340     if ($href === null)
341       throw new MalformedGPXException(
342         'Missing required attribute "href" in "link"'
343       );
344     $struct = [
345       'elements' => [
346         'text' => function ($link) {
347           $link->setText($this->readXSDString());
348         },
349         'type' => function ($link) {
350           $link->setType($this->readXSDString());
351         }
352       ]
353     ];
354     $link = new Link($href);
355     $this->readStruct($struct, $link);
356     return $link;
357   }
358
359   /**
360    * Read a copyright type.
361    *
362    * @return Copyright The copyright data.
363    * @throws MalformedGPXException If the GPX file was invalid or not supported.
364    */
365   protected function readCopyright()
366   {
367     $author = $this->xml->getAttribute('author');
368     if ($author === null)
369       throw new MalformedGPXException(
370         'Missing required attribute "author" in "copyright"'
371       );
372     $struct = [
373       'elements' => [
374         'year' => function ($copyright) {
375           try {
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)
381             );
382           }
383         },
384         'license' => function ($copyright) {
385           $copyright->setLicense($this->readXSDString());
386         }
387       ]
388     ];
389     $copyright = new Copyright($author);
390     $this->readStruct($struct, $copyright);
391     return $copyright;
392   }
393
394   /**
395    * Read an extensions type.
396    *
397    * This is suitable for GPX 1.1 only as private elements are mixed into other
398    * elements in GPX 1.0.
399    *
400    * @param TypedDoublyLinkedList $list The list to fill with extension XML.
401    * @return void
402    * @throws MalformedGPXException If the GPX file was invalid.
403    */
404   protected function readExtension(TypedDoublyLinkedList $list)
405   {
406     $struct = [
407       'extensions' => function ($list) {
408         $list[] = $this->xml->readOuterXml();
409         $this->xml->next();
410       }
411     ];
412     $this->readStruct($struct, $list);
413   }
414
415   /**
416    * Read a data structure from XML.
417    *
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:
422    *
423    *     [
424    *       'extensions' => function($object, &$state) {...},
425    *       'elements' => [
426    *         'nodename' => function($object, &$state) {...}
427    *       ]
428    *     ]
429    *
430    * The following keys are defined:
431    *
432    *   * `extensions` - Call the supplied anonymous function if any XML element
433    *                    is encountered in a namespace other than the document
434    *                    namespace.
435    *   * `elements`   - Call the supplied anonymous function if an XML element
436    *                    is encountered in the document namespace with a matching
437    *                    node name.
438    *
439    * The anonymous function parameters are as follows:
440    *
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.
445    *
446    * @param array $struct a data structure as defined above.
447    * @param object $object The object to populate with data.
448    * @return void
449    * @throws MalformedGPXException If the GPX file was invalid or not supported.
450    */
451   protected function readStruct($struct, $object)
452   {
453     $xml = $this->xml;
454     if ($xml->isEmptyElement) {
455       $xml->read();
456       return;
457     }
458     $element = $xml->localName;
459     $xml->read();
460     $state = [];
461     while (true) {
462       if (
463         $xml->nodeType == XMLReader::END_ELEMENT
464         && $xml->namespaceURI == $this->ns
465         && $xml->localName == $element
466       ) return;
467       if ($xml->nodeType != XMLReader::ELEMENT) {
468         $xml->read();
469         continue;
470       }
471       if (
472         $xml->namespaceURI == $this->ns
473         && isset($struct['elements'][$xml->localName])
474       ) {
475         if (is_callable($struct['elements'][$xml->localName])) {
476           $struct['elements'][$xml->localName]($object, $state);
477         } else {
478           $xml->read();
479         }
480         continue;
481       }
482       if (
483         $xml->namespaceURI != $this->ns
484         && isset($struct['extensions'])
485         && is_callable($struct['extensions'])
486       ) {
487         $struct['extensions']($object, $state);
488         continue;
489       }
490       throw new MalformedGPXException(
491         sprintf(
492           'Unknown element "%s" in element "%s"',
493           $xml->localName,
494           $element
495         )
496       );
497     }
498   }
499
500   /**
501    * Read an `xsd:string` type from XML.
502    *
503    * @return string The text string.
504    * @throws MalformedGPXException If the element has non-text children.
505    */
506   protected function readXSDString()
507   {
508     $xml = $this->xml;
509     $result = '';
510     if ($xml->nodeType != XMLReader::ELEMENT || $xml->isEmptyElement)
511       return $result;
512     $element = $xml->name;
513     while ($xml->read()) {
514       switch ($xml->nodeType) {
515         case XMLReader::TEXT:
516         case XMLReader::CDATA:
517           $result .= $xml->value;
518           break;
519         case XMLReader::END_ELEMENT:
520           $xml->read();
521           break 2;
522         default:
523           throw new MalformedGPXException(
524             sprintf('Element "%s" contains non-text data.', $element)
525           );
526       }
527
528     }
529     return $result;
530   }
531
532   /**
533    * Convert a string to DateTime.
534    *
535    * @param string $timestamp The timestamp to convert.
536    * @return DateTime The parsed DateTime.
537    * @throws MalformedGPXException If the timestamp could not be parsed.
538    */
539   protected function string2DateTime(string $timestamp)
540   {
541     try {
542       $result = new DateTime($timestamp, new DateTimeZone('Z'));
543     } catch (Exception $e) {
544       throw new MalformedGPXException(
545         sprintf('Unknown datetime format "%s"', $timestamp)
546       );
547     }
548     return $result;
549   }
550
551 }