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