]> git.street.me.uk Git - andy/gpx.git/blame - src/libgpx/gpxreader.php
Add ability to read track types
[andy/gpx.git] / src / libgpx / gpxreader.php
CommitLineData
88564339
AS
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
25namespace libgpx;
26
27use \DateTime;
28use \DateTimeZone;
f528d248 29use \DomainException;
88564339
AS
30use \Exception;
31use \InvalidArgumentException;
32use \RuntimeException;
33
34/**
35 * Read GPX files.
36 *
37 * @author Andy Street <andy@street.me.uk>
38 */
39class 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 = [
f528d248
AS
180 'elements' => [
181 'wpt' => function ($gpx) {
182 $gpx->getWaypoints()[] = $this->readWpt();
f17d2566
AS
183 },
184 'rte' => function ($gpx) {
185 $gpx->getRoutes()[] = $this->readRte();
35c309cc
AS
186 },
187 'trk' => function ($gpx) {
188 $gpx->getTracks()[] = $this->readTrk();
f528d248
AS
189 }
190 ]
88564339
AS
191 ];
192 if ($this->version == '1.1') {
193 $struct['elements']['metadata'] = function ($gpx) {
194 $this->readMetadata($gpx);
195 };
196 } else {
197 $struct['elements']['name'] = function ($gpx) {
198 $gpx->setName($this->readXSDString());
199 };
200 $struct['elements']['desc'] = function ($gpx) {
201 $gpx->setDesc($this->readXSDString());
202 };
203 $struct['elements']['author'] = function ($gpx) {
204 $author = $gpx->getAuthor();
205 if ($author === null) {
206 $author = new Person();
207 $gpx->setAuthor($author);
208 }
209 $author->setName($this->readXSDString());
210 };
211 $struct['elements']['email'] = function ($gpx) {
212 $author = $gpx->getAuthor();
213 if ($author === null) {
214 $author = new Person();
215 $gpx->setAuthor($author);
216 }
217 $author->setEmail($this->readXSDString());
218 };
219 $struct['elements']['url'] = function ($gpx, &$state) {
220 $href = $this->readXSDString();
221 $links = $gpx->getLinks();
f528d248 222 if ($links->isEmpty()) {
88564339
AS
223 $link = new Link($href);
224 if (isset($state['urlname'])) {
225 $link->setText($state['urlname']);
226 unset($state['urlname']);
227 }
7c465a82
AS
228 $links[] = $link;
229 } else {
230 $links->bottom()->setHref($href);
88564339
AS
231 }
232 };
233 $struct['elements']['urlname'] = function ($gpx, &$state) {
234 $text = $this->readXSDString();
235 $links = $gpx->getLinks();
f528d248 236 if ($links->isEmpty()) {
88564339 237 $state['urlname'] = $text;
7c465a82
AS
238 } else {
239 $links->bottom()->setText($text);
88564339
AS
240 }
241 };
242 $struct['elements']['time'] = function ($gpx) {
243 $gpx->setTime($this->string2DateTime($this->readXSDString()));
244 };
245 $struct['elements']['keywords'] = function ($gpx) {
7c465a82
AS
246 $keywords = $gpx->getKeywords();
247 $words = explode(',', $this->readXSDString());
248 foreach ($words as $word)
249 $keywords[] = trim($word);
88564339
AS
250 };
251 $struct['elements']['bounds'] = false;
252 }
253
254 $this->readStruct($struct, $result);
255 return $result;
256 }
257
258 /**
259 * Read a metadata type.
260 *
261 * Note: This is GPX 1.1 only as GPX 1.0 does not have a metadata type.
262 *
263 * @param GPX $gpx The gpx object to populate.
264 * @return void
265 * @throws MalformedGPXException If the GPX file was invalid or not supported.
266 */
267 protected function readMetadata(GPX $gpx)
268 {
269 $struct = [
270 'elements' => [
271 'name' => function ($gpx) {
272 $gpx->setName($this->readXSDString());
273 },
274 'desc' => function ($gpx) {
275 $gpx->setDesc($this->readXSDString());
276 },
277 'author' => function ($gpx) {
278 $gpx->setAuthor($this->readPerson());
279 },
280 'copyright' => function ($gpx) {
281 $gpx->setCopyright($this->readCopyright());
282 },
283 'link' => function ($gpx) {
7c465a82
AS
284 $links = $gpx->getLinks();
285 $links[] = $this->readLink();
88564339
AS
286 },
287 'time' => function ($gpx) {
288 $gpx->setTime($this->string2DateTime($this->readXSDString()));
289 },
290 'keywords' => function ($gpx) {
7c465a82
AS
291 $keywords = $gpx->getKeywords();
292 $words = explode(',', $this->readXSDString());
293 foreach ($words as $word)
294 $keywords[] = trim($word);
88564339 295 },
7c465a82
AS
296 'bounds' => false, // Not required as it is calculated internally.
297 'extensions' => function ($gpx) {
298 $this->readExtension($gpx->getMetadataExtensions());
299 }
88564339
AS
300 ]
301 ];
302 $this->readStruct($struct, $gpx);
303 }
304
305 /**
306 * Read a person type.
307 *
308 * @return Person The person data.
309 * @throws MalformedGPXException If the GPX file was invalid or not supported.
310 */
311 protected function readPerson()
312 {
313 $struct = [
314 'elements' => [
315 'name' => function ($person) {
316 $person->setName($this->readXSDString());
317 },
318 'email' => function ($person) {
319 $id = $this->xml->getAttribute('id');
320 if ($id === null)
321 throw new MalformedGPXException(
322 'Missing required attribute "id" in "email"'
323 );
324 $domain = $this->xml->getAttribute('domain');
325 if ($domain === null)
326 throw new MalformedGPXException(
327 'Missing required attribute "domain" in "email"'
328 );
329 $person->setEmail($id . '@' . $domain);
330 $this->xml->read();
331 },
332 'link' => function ($person) {
333 $person->setLink($this->readLink());
334 }
335 ]
336 ];
337 $person = new Person();
338 $this->readStruct($struct, $person);
339 return $person;
340 }
341
342 /**
343 * Read a link type.
344 *
345 * @return Link The link data.
346 * @throws MalformedGPXException If the GPX file was invalid or not supported.
347 */
348 protected function readLink()
349 {
350 $href = $this->xml->getAttribute('href');
351 if ($href === null)
352 throw new MalformedGPXException(
353 'Missing required attribute "href" in "link"'
354 );
355 $struct = [
356 'elements' => [
357 'text' => function ($link) {
358 $link->setText($this->readXSDString());
359 },
360 'type' => function ($link) {
361 $link->setType($this->readXSDString());
362 }
363 ]
364 ];
365 $link = new Link($href);
366 $this->readStruct($struct, $link);
367 return $link;
368 }
369
370 /**
371 * Read a copyright type.
372 *
373 * @return Copyright The copyright data.
374 * @throws MalformedGPXException If the GPX file was invalid or not supported.
375 */
376 protected function readCopyright()
377 {
378 $author = $this->xml->getAttribute('author');
379 if ($author === null)
380 throw new MalformedGPXException(
381 'Missing required attribute "author" in "copyright"'
382 );
383 $struct = [
384 'elements' => [
385 'year' => function ($copyright) {
386 try {
387 $year = $this->readXSDString();
388 $copyright->setYear($year);
389 } catch (InvalidArgumentException $e) {
390 throw new MalformedGPXException(
391 sprintf('"%s" is not a valid year', $year)
392 );
393 }
394 },
395 'license' => function ($copyright) {
396 $copyright->setLicense($this->readXSDString());
397 }
398 ]
399 ];
400 $copyright = new Copyright($author);
401 $this->readStruct($struct, $copyright);
402 return $copyright;
403 }
404
7c465a82
AS
405 /**
406 * Read an extensions type.
407 *
408 * This is suitable for GPX 1.1 only as private elements are mixed into other
409 * elements in GPX 1.0.
410 *
411 * @param TypedDoublyLinkedList $list The list to fill with extension XML.
412 * @return void
413 * @throws MalformedGPXException If the GPX file was invalid.
414 */
415 protected function readExtension(TypedDoublyLinkedList $list)
416 {
417 $struct = [
418 'extensions' => function ($list) {
419 $list[] = $this->xml->readOuterXml();
420 $this->xml->next();
421 }
422 ];
423 $this->readStruct($struct, $list);
424 }
425
f528d248
AS
426 /**
427 * Read a wpt type.
428 *
429 * @see https://www.topografix.com/GPX/1/1/#type_wptType
430 *
431 * @return Point
432 * @throws MalformedGPXException If the GPX file was invalid or not supported.
433 */
434 protected function readWpt()
435 {
436 try {
437 $result = new Point(
438 $this->string2float($this->xml->getAttribute('lat')),
439 $this->string2float($this->xml->getAttribute('lon'))
440 );
09a36623
AS
441 $struct = $this->getDataTypeStruct();
442 $struct['elements']['ele'] = function ($point) {
443 $point->setEle($this->string2float($this->readXSDString()));
444 };
445 $struct['elements']['time'] = function ($point) {
446 $point->setTime($this->string2DateTime($this->readXSDString()));
447 };
448 $struct['elements']['magvar'] = function ($point) {
449 $point->setMagvar($this->string2float($this->readXSDString()));
450 };
451 $struct['elements']['geoidheight'] = function ($point) {
452 $point->setGeoidHeight($this->string2float($this->readXSDString()));
453 };
454 $struct['elements']['sym'] = function ($point) {
455 $point->setSymbol($this->readXSDString());
456 };
457 // "type" only appears in "wpt" in GPX 1.0
458 if ($this->version == '1.0')
459 $struct['elements']['type'] = function ($point) {
460 $point->setType($this->readXSDString());
f528d248 461 };
09a36623
AS
462 $struct['elements']['fix'] = function ($point) {
463 $point->setFix($this->readXSDString());
464 };
465 $struct['elements']['sat'] = function ($point) {
466 $point->setSatellites($this->string2int($this->readXSDString()));
467 };
468 $struct['elements']['hdop'] = function ($point) {
469 $point->setHdop($this->string2float($this->readXSDString()));
470 };
471 $struct['elements']['vdop'] = function ($point) {
472 $point->setVdop($this->string2float($this->readXSDString()));
473 };
474 $struct['elements']['pdop'] = function ($point) {
475 $point->setPdop($this->string2float($this->readXSDString()));
476 };
477 $struct['elements']['ageofdgpsdata'] = function ($point) {
478 $point->setAgeOfDGPSData($this->string2float($this->readXSDString()));
479 };
480 $struct['elements']['dgpsid'] = function ($point) {
481 $point->setDGPSId($this->string2int($this->readXSDString()));
482 };
f528d248 483 $this->readStruct($struct, $result);
f17d2566
AS
484 } catch (DomainException $e) {
485 throw new MalformedGPXException(
486 $e->getMessage(), $e->getCode(), $e
487 );
488 }
489 return $result;
490 }
491
492 /**
493 * Read a rte type.
494 *
495 * @see https://www.topografix.com/GPX/1/1/#type_rteType
496 *
497 * @return Route
498 * @throws MalformedGPXException If the GPX file was invalid or not supported.
499 */
500 protected function readRte()
501 {
502 try {
503 $result = new Route();
09a36623
AS
504 $struct = $this->getDataTypeStruct();
505 $struct['elements']['number'] = function ($route) {
506 $route->setNumber($this->string2int($this->readXSDString()));
507 };
508 $struct['elements']['rtept'] = function ($route) {
509 $route->getPoints()[] = $this->readWpt();
510 };
f17d2566 511 $this->readStruct($struct, $result);
f528d248
AS
512 } catch (DomainException $e) {
513 throw new MalformedGPXException(
514 $e->getMessage(), $e->getCode(), $e
515 );
516 }
517 return $result;
518 }
519
35c309cc
AS
520 /**
521 * Read a trk type.
522 *
523 * @see https://www.topografix.com/GPX/1/1/#type_trkType
524 *
525 * @return Track
526 * @throws MalformedGPXException If the GPX file was invalid or not supported.
527 */
528 protected function readTrk()
529 {
530 try {
531 $result = new Track();
532 $struct = $this->getDataTypeStruct();
533 $struct['elements']['number'] = function ($track) {
534 $track->setNumber($this->string2int($this->readXSDString()));
535 };
536 $struct['elements']['trkseg'] = function ($track) {
537 $track->getSegments()[] = $this->readTrkseg();
538 };
539 $this->readStruct($struct, $result);
540 } catch (DomainException $e) {
541 throw new MalformedGPXException(
542 $e->getMessage(), $e->getCode(), $e
543 );
544 }
545 return $result;
546 }
547
548 /**
549 * Read a trkseg type.
550 *
551 * @see https://www.topografix.com/GPX/1/1/#type_trksegType
552 *
553 * @return TrackSegment
554 * @throws MalformedGPXException If the GPX file was invalid or not supported.
555 */
556 protected function readTrkseg()
557 {
558 $result = new TrackSegment();
559 $struct = [
560 'elements' => [
561 'trkpt' => function ($segment) {
562 $segment->getPoints()[] = $this->readWpt();
563 }
564 ]
565 ];
566 if ($this->version == '1.1')
567 $struct['elements']['extensions'] = function ($segment) {
568 $this->readExtension($segment->getExtensions());
569 };
570 $this->readStruct($struct, $result);
571 return $result;
572 }
573
88564339
AS
574 /**
575 * Read a data structure from XML.
576 *
577 * This is a generalized function for parsing XML data structures and
578 * populating values based on data retrieved. The main control element is the
579 * `$struct` array which holds a list of callable functions which are used to
580 * modify the supplied object. The structure of the array is:
581 *
582 * [
7c465a82 583 * 'extensions' => function($object, &$state) {...},
88564339 584 * 'elements' => [
7c465a82 585 * 'nodename' => function($object, &$state) {...}
88564339
AS
586 * ]
587 * ]
588 *
7c465a82
AS
589 * The following keys are defined:
590 *
591 * * `extensions` - Call the supplied anonymous function if any XML element
592 * is encountered in a namespace other than the document
593 * namespace.
594 * * `elements` - Call the supplied anonymous function if an XML element
595 * is encountered in the document namespace with a matching
596 * node name.
597 *
88564339
AS
598 * The anonymous function parameters are as follows:
599 *
600 * * `$object` - The object to be populated (as passed to this function)
601 * * `$state` - An array that can be used to store data temporarily while
602 * the structure is being written. This is useful if a value
603 * that is being read is split across several XML elements.
604 *
605 * @param array $struct a data structure as defined above.
606 * @param object $object The object to populate with data.
607 * @return void
608 * @throws MalformedGPXException If the GPX file was invalid or not supported.
609 */
610 protected function readStruct($struct, $object)
611 {
612 $xml = $this->xml;
613 if ($xml->isEmptyElement) {
614 $xml->read();
615 return;
616 }
617 $element = $xml->localName;
618 $xml->read();
619 $state = [];
620 while (true) {
621 if (
622 $xml->nodeType == XMLReader::END_ELEMENT
623 && $xml->namespaceURI == $this->ns
624 && $xml->localName == $element
625 ) return;
626 if ($xml->nodeType != XMLReader::ELEMENT) {
627 $xml->read();
628 continue;
629 }
630 if (
631 $xml->namespaceURI == $this->ns
632 && isset($struct['elements'][$xml->localName])
633 ) {
634 if (is_callable($struct['elements'][$xml->localName])) {
635 $struct['elements'][$xml->localName]($object, $state);
636 } else {
637 $xml->read();
638 }
639 continue;
640 }
7c465a82
AS
641 if (
642 $xml->namespaceURI != $this->ns
643 && isset($struct['extensions'])
644 && is_callable($struct['extensions'])
645 ) {
646 $struct['extensions']($object, $state);
647 continue;
648 }
88564339
AS
649 throw new MalformedGPXException(
650 sprintf(
651 'Unknown element "%s" in element "%s"',
652 $xml->localName,
653 $element
654 )
655 );
656 }
657 }
658
09a36623
AS
659 /**
660 * Generate a data structure for reading DataType elements.
661 *
662 * @return array
663 */
664 protected function getDataTypeStruct()
665 {
666 $result = [
667 'elements' => [
668 'name' => function ($type) {
669 $type->setName($this->readXSDString());
670 },
671 'cmt' => function ($type) {
672 $type->setComment($this->readXSDString());
673 },
674 'desc' => function ($type) {
675 $type->setDescription($this->readXSDString());
676 },
677 'src' => function ($type) {
678 $type->setSource($this->readXSDString());
679 }
680 ]
681 ];
682 if ($this->version == '1.1') {
683 $result['elements']['link'] = function ($type) {
684 $type->getLinks()[] = $this->readLink();
685 };
686 $result['elements']['type'] = function ($type) {
687 $type->setType($this->readXSDString());
688 };
689 $result['elements']['extensions'] = function ($type) {
690 $this->readExtension($type->getExtensions());
691 };
692 } else {
693 $result['elements']['url'] = function ($type, &$state) {
694 $href = $this->readXSDString();
695 $links = $type->getLinks();
696 if ($links->isEmpty()) {
697 $link = new Link($href);
698 if (isset($state['urlname'])) {
699 $link->setText($state['urlname']);
700 unset($state['urlname']);
701 }
702 $links[] = $link;
703 } else {
704 $links->bottom()->setHref($href);
705 }
706 };
707 $result['elements']['urlname'] = function ($type, &$state) {
708 $text = $this->readXSDString();
709 $links = $type->getLinks();
710 if ($links->isEmpty()) {
711 $state['urlname'] = $text;
712 } else {
713 $links->bottom()->setText($text);
714 }
715 };
716 $result['extensions'] = function ($type) {
717 $type->getExtensions()[] = $this->xml->readOuterXML();
718 $this->xml->next();
719 };
720 }
721 return $result;
722 }
723
88564339
AS
724 /**
725 * Read an `xsd:string` type from XML.
726 *
727 * @return string The text string.
728 * @throws MalformedGPXException If the element has non-text children.
729 */
730 protected function readXSDString()
731 {
732 $xml = $this->xml;
733 $result = '';
734 if ($xml->nodeType != XMLReader::ELEMENT || $xml->isEmptyElement)
735 return $result;
736 $element = $xml->name;
737 while ($xml->read()) {
738 switch ($xml->nodeType) {
739 case XMLReader::TEXT:
740 case XMLReader::CDATA:
741 $result .= $xml->value;
742 break;
743 case XMLReader::END_ELEMENT:
744 $xml->read();
745 break 2;
746 default:
747 throw new MalformedGPXException(
748 sprintf('Element "%s" contains non-text data.', $element)
749 );
750 }
751
752 }
753 return $result;
754 }
755
756 /**
757 * Convert a string to DateTime.
758 *
759 * @param string $timestamp The timestamp to convert.
760 * @return DateTime The parsed DateTime.
761 * @throws MalformedGPXException If the timestamp could not be parsed.
762 */
763 protected function string2DateTime(string $timestamp)
764 {
765 try {
766 $result = new DateTime($timestamp, new DateTimeZone('Z'));
767 } catch (Exception $e) {
768 throw new MalformedGPXException(
769 sprintf('Unknown datetime format "%s"', $timestamp)
770 );
771 }
772 return $result;
773 }
774
f528d248
AS
775 /**
776 * Convert a string to a float.
777 *
778 * @param string $value The string value to convert.
779 * @return float
780 * @throws MalformedGPXException If the value is not numeric.
781 */
782 protected function string2float(string $value)
783 {
784 if (!is_numeric($value))
785 throw new MalformedGPXException(
786 sprintf('Expected decimal value but got "%s"', $value)
787 );
788 return floatval($value);
789 }
790
791 /**
792 * Convert a string to a int.
793 *
794 * @param string $value The string value to convert.
795 * @return int
796 * @throws MalformedGPXException If the value is not numeric.
797 */
798 protected function string2int(string $value)
799 {
800 if (!is_numeric($value))
801 throw new MalformedGPXException(
802 sprintf('Expected decimal value but got "%s"', $value)
803 );
804 return intval($value);
805 }
806
88564339 807}