1 <?php
2 
3 namespace geoPHP\Adapter;
4 
5 use DOMDocument;
6 use DOMElement;
7 use DOMXPath;
8 use geoPHP\Geometry\Collection;
9 use geoPHP\geoPHP;
10 use geoPHP\Geometry\Geometry;
11 use geoPHP\Geometry\GeometryCollection;
12 use geoPHP\Geometry\Point;
13 use geoPHP\Geometry\LineString;
14 use geoPHP\Geometry\MultiLineString;
15 
16 /*
17  * Copyright (c) Patrick Hayes
18  *
19  * This code is open-source and licenced under the Modified BSD License.
20  * For the full copyright and license information, please view the LICENSE
21  * file that was distributed with this source code.
22  */
23 
24 /**
25  * PHP Geometry/GPX encoder/decoder
26  */
27 class GPX implements GeoAdapter
28 {
29 
30     protected $nss = ''; // Name-space string. eg 'georss:'
31 
32     /**
33      * @var GpxTypes
34      */
35     protected $gpxTypes;
36 
37     /**
38      * @var DOMXPath
39      */
40     protected $xpath;
41 
42     protected $parseGarminRpt = false;
43 
44     protected $trackFromRoute = null;
45 
46     /**
47      * Read GPX string into geometry object
48      *
49      * @param string $gpx A GPX string
50      * @param array|null $allowedElements Which elements can be read from each GPX type
51      *                   If not specified, every element defined in the GPX specification can be read
52      *                   Can be overwritten with an associative array, with type name in keys.
53      *                   eg.: ['wptType' => ['ele', 'name'], 'trkptType' => ['ele'], 'metadataType' => null]
54      * @return Geometry|GeometryCollection
55      * @throws \Exception If GPX is not a valid XML
56      */
57     public function read($gpx, $allowedElements = null)
58     {
59         $this->gpxTypes = new GpxTypes($allowedElements);
60 
61         //libxml_use_internal_errors(true); // why?
62 
63         // Load into DOMDocument
64         $xmlObject = new DOMDocument('1.0', 'UTF-8');
65         $xmlObject->preserveWhiteSpace = false;
66         @$xmlObject->loadXML($gpx);
67         if ($xmlObject === false) {
68             throw new \Exception("Invalid GPX: " . $gpx);
69         }
70 
71         $this->parseGarminRpt = strpos($gpx, 'gpxx:rpt') > 0;
72 
73         // Initialize XPath parser if needed (currently only for Garmin extensions)
74         if ($this->parseGarminRpt) {
75             $this->xpath = new DOMXPath($xmlObject);
76             $this->xpath->registerNamespace('gpx', 'http://www.topografix.com/GPX/1/1');
77             $this->xpath->registerNamespace('gpxx', 'http://www.garmin.com/xmlschemas/GpxExtensions/v3');
78         }
79 
80         try {
81             $geom = $this->geomFromXML($xmlObject);
82             if ($geom->isEmpty()) {
83                 /* Geometry was empty but maybe because its tags was not lower cased.
84                    We try to lower-case tags and try to run again, but just once.
85                 */
86                 $backtrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2);
87                 $caller = isset($backtrace[1]['function']) ? $backtrace[1]['function'] : null;
88                 if ($caller && $caller !== __FUNCTION__) {
89                     $gpx = preg_replace_callback(
90                         "/(<\/?\w+)(.*?>)/",
91                         function ($m) {
92                             return strtolower($m[1]) . $m[2];
93                         },
94                         $gpx
95                     );
96                     $geom = $this->read($gpx, $allowedElements);
97                 }
98             }
99         } catch (\Exception $e) {
100             throw new \Exception("Cannot Read Geometry From GPX: " . $gpx);
101         }
102 
103         return $geom;
104     }
105 
106     /**
107      * Parses the GPX XML and returns a geometry
108      * @param DOMDocument $xmlObject
109      * @return GeometryCollection|Geometry Returns the geometry representation of the GPX (@see geoPHP::buildGeometry)
110      */
111     protected function geomFromXML($xmlObject)
112     {
113         /** @var Geometry[] $geometries */
114         $geometries = array_merge(
115             $this->parseWaypoints($xmlObject),
116             $this->parseTracks($xmlObject),
117             $this->parseRoutes($xmlObject)
118         );
119 
120         if (isset($this->trackFromRoute)) {
121             $trackFromRoute = new LineString($this->trackFromRoute);
122             $trackFromRoute->setData('gpxType', 'track');
123             $trackFromRoute->setData('type', 'planned route');
124             $geometries[] = $trackFromRoute;
125         }
126 
127         $geometry = geoPHP::buildGeometry($geometries);
128         if (in_array('metadata', $this->gpxTypes->get('gpxType')) && $xmlObject->getElementsByTagName('metadata')->length === 1) {
129             $metadata = self::parseNodeProperties(
130                 $xmlObject->getElementsByTagName('metadata')->item(0),
131                 $this->gpxTypes->get('metadataType')
132             );
133             if ($geometry->getData() !== null && $metadata !== null) {
134                 $geometry = new GeometryCollection([$geometry]);
135             }
136             $geometry->setData($metadata);
137         }
138 
139         return $geometry;
140     }
141 
142     protected function childElements($xml, $nodeName = '')
143     {
144         $children = [];
145         foreach ($xml->childNodes as $child) {
146             if ($child->nodeName == $nodeName) {
147                 $children[] = $child;
148             }
149         }
150         return $children;
151     }
152 
153     /**
154      * @param DOMElement $node
155      * @return Point
156      */
157     protected function parsePoint($node)
158     {
159         $lat = $node->attributes->getNamedItem("lat")->nodeValue;
160         $lon = $node->attributes->getNamedItem("lon")->nodeValue;
161         $elevation = null;
162         $ele = $node->getElementsByTagName('ele');
163         if ($ele->length) {
164             $elevation = $ele->item(0)->nodeValue;
165         }
166         $point = new Point($lon, $lat, $elevation);
167         $point->setData($this->parseNodeProperties($node, $this->gpxTypes->get($node->nodeName . 'Type')));
168         if ($node->nodeName === 'rtept' && $this->parseGarminRpt) {
169             foreach ($this->xpath->query('.//gpx:extensions/gpxx:RoutePointExtension/gpxx:rpt', $node) as $element) {
170                 $this->trackFromRoute[] = $this->parsePoint($element);
171             }
172         }
173         return $point;
174     }
175 
176     /**
177      * @param DOMDocument $xmlObject
178      * @return Point[]
179      */
180     protected function parseWaypoints($xmlObject)
181     {
182         if (!in_array('wpt', $this->gpxTypes->get('gpxType'))) {
183             return [];
184         }
185         $points = [];
186         $wptElements = $xmlObject->getElementsByTagName('wpt');
187         foreach ($wptElements as $wpt) {
188             $point = $this->parsePoint($wpt);
189             $point->setData('gpxType', 'waypoint');
190             $points[] = $point;
191         }
192         return $points;
193     }
194 
195     /**
196      * @param DOMDocument $xmlObject
197      * @return LineString[]
198      */
199     protected function parseTracks($xmlObject)
200     {
201         if (!in_array('trk', $this->gpxTypes->get('gpxType'))) {
202             return [];
203         }
204         $tracks = [];
205         $trkElements = $xmlObject->getElementsByTagName('trk');
206         foreach ($trkElements as $trk) {
207             $segments = [];
208             /** @noinspection SpellCheckingInspection */
209             foreach ($this->childElements($trk, 'trkseg') as $trkseg) {
210                 $points = [];
211                 /** @noinspection SpellCheckingInspection */
212                 foreach ($this->childElements($trkseg, 'trkpt') as $trkpt) {
213                     $points[] = $this->parsePoint($trkpt);
214                 }
215                 // Avoids creating invalid LineString
216                 $segments[] = new LineString(count($points) <> 1 ? $points : []);
217             }
218             $track = count($segments) === 0
219                     ? new LineString()
220                     : (count($segments) === 1
221                             ? $segments[0]
222                             : new MultiLineString($segments));
223             $track->setData($this->parseNodeProperties($trk, $this->gpxTypes->get('trkType')));
224             $track->setData('gpxType', 'track');
225             $tracks[] = $track;
226         }
227         return $tracks;
228     }
229 
230     /**
231      * @param DOMDocument $xmlObject
232      * @return LineString[]
233      */
234     protected function parseRoutes($xmlObject)
235     {
236         if (!in_array('rte', $this->gpxTypes->get('gpxType'))) {
237             return [];
238         }
239         $lines = [];
240         $rteElements = $xmlObject->getElementsByTagName('rte');
241         foreach ($rteElements as $rte) {
242             $components = [];
243             /** @noinspection SpellCheckingInspection */
244             foreach ($this->childElements($rte, 'rtept') as $routePoint) {
245                 /** @noinspection SpellCheckingInspection */
246                 $components[] = $this->parsePoint($routePoint);
247             }
248             $line = new LineString($components);
249             $line->setData($this->parseNodeProperties($rte, $this->gpxTypes->get('rteType')));
250             $line->setData('gpxType', 'route');
251             $lines[] = $line;
252         }
253         return $lines;
254     }
255 
256     /**
257      * Parses a DOMNode and returns its content in a multidimensional associative array
258      * eg: <wpt><name>Test</name><link href="example.com"><text>Example</text></link></wpt>
259      * to: ['name' => 'Test', 'link' => ['text'] => 'Example', '@attributes' => ['href' => 'example.com']]
260      *
261      * @param \DOMNode $node
262      * @param string[]|null $tagList
263      * @return array|string
264      */
265     protected static function parseNodeProperties($node, $tagList = null)
266     {
267         if ($node->nodeType === XML_TEXT_NODE) {
268             return $node->nodeValue;
269         }
270         $result = [];
271         foreach ($node->childNodes as $childNode) {
272             /** @var \DOMNode $childNode */
273             if ($childNode->hasChildNodes()) {
274                 if ($tagList === null || in_array($childNode->nodeName, $tagList ?: [])) {
275                     if ($node->firstChild->nodeName == $node->lastChild->nodeName && $node->childNodes->length > 1) {
276                         $result[$childNode->nodeName][] = self::parseNodeProperties($childNode);
277                     } else {
278                         $result[$childNode->nodeName] = self::parseNodeProperties($childNode);
279                     }
280                 }
281             } elseif ($childNode->nodeType === 1 && in_array($childNode->nodeName, $tagList ?: [])) {
282                 $result[$childNode->nodeName] = self::parseNodeProperties($childNode);
283             } elseif ($childNode->nodeType === 3) {
284                 $result = $childNode->nodeValue;
285             }
286         }
287         if ($node->hasAttributes()) {
288             if (is_string($result)) {
289                 // As of the GPX specification text node cannot have attributes, thus this never happens
290                 $result = ['#text' => $result];
291             }
292             $attributes = [];
293             foreach ($node->attributes as $attribute) {
294                 if ($attribute->name !== 'lat' && $attribute->name !== 'lon' && trim($attribute->value) !== '') {
295                     $attributes[$attribute->name] = trim($attribute->value);
296                 }
297             }
298             if (count($attributes)) {
299                 $result['@attributes'] = $attributes;
300             }
301         }
302         return $result;
303     }
304 
305 
306     /**
307      * Serialize geometries into a GPX string.
308      *
309      * @param Geometry|GeometryCollection $geometry
310      * @param string|null $namespace
311      * @param array|null $allowedElements Which elements can be added to each GPX type
312      *                   If not specified, every element defined in the GPX specification can be added
313      *                   Can be overwritten with an associative array, with type name in keys.
314      *                   eg.: ['wptType' => ['ele', 'name'], 'trkptType' => ['ele'], 'metadataType' => null]
315      * @return string The GPX string representation of the input geometries
316      */
317     public function write(Geometry $geometry, $namespace = null, $allowedElements = null)
318     {
319         if ($namespace) {
320             $this->nss = $namespace . ':';
321         }
322         $this->gpxTypes = new GpxTypes($allowedElements);
323 
324         return
325         '<?xml version="1.0" encoding="UTF-8"?>
326 <' . $this->nss . 'gpx creator="geoPHP" version="1.1"
327   xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
328   xmlns="http://www.topografix.com/GPX/1/1"
329   xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd" >
330 
331 ' . $this->geometryToGPX($geometry) .
332         '</' . $this->nss . 'gpx>
333 ';
334     }
335 
336     /**
337      * @param Geometry|Collection $geometry
338      * @return string
339      */
340     protected function geometryToGPX($geometry)
341     {
342         switch ($geometry->geometryType()) {
343             case Geometry::POINT:
344                 /** @var Point $geometry */
345                 return $this->pointToGPX($geometry);
346             case Geometry::LINE_STRING:
347             case Geometry::MULTI_LINE_STRING:
348                 /** @var LineString $geometry */
349                 return $this->linestringToGPX($geometry);
350             case Geometry::POLYGON:
351             case Geometry::MULTI_POINT:
352             case Geometry::MULTI_POLYGON:
353             case Geometry::GEOMETRY_COLLECTION:
354                 return $this->collectionToGPX($geometry);
355         }
356         return '';
357     }
358 
359     /**
360      * @param Point $geom
361      * @param string $tag Can be "wpt", "trkpt" or "rtept"
362      * @return string
363      */
364     private function pointToGPX($geom, $tag = 'wpt')
365     {
366         if ($geom->isEmpty() || ($tag === 'wpt' && !in_array($tag, $this->gpxTypes->get('gpxType')))) {
367             return '';
368         }
369         $indent = $tag === 'trkpt' ? "\t\t" : ($tag === 'rtept' ? "\t" : '');
370 
371         if ($geom->hasZ() || $geom->getData() !== null) {
372             $node = $indent . "<" . $this->nss . $tag . " lat=\"" . $geom->getY() . "\" lon=\"" . $geom->getX() . "\">\n";
373             if ($geom->hasZ()) {
374                 $geom->setData('ele', $geom->z());
375             }
376             $node .= self::processGeometryData($geom, $this->gpxTypes->get($tag . 'Type'), $indent . "\t") .
377                     $indent . "</" . $this->nss . $tag . ">\n";
378             if ($geom->hasZ()) {
379                 $geom->setData('ele', null);
380             }
381             return $node;
382         }
383         return $indent . "<" . $this->nss . $tag . " lat=\"" . $geom->getY() . "\" lon=\"" . $geom->getX() . "\" />\n";
384     }
385 
386     /**
387      * Writes a LineString or MultiLineString to the GPX
388      *
389      * The (Multi)LineString will be included in a <trk></trk> block
390      * The LineString or each LineString of the MultiLineString will be in <trkseg> </trkseg> inside the <trk>
391      *
392      * @param LineString|MultiLineString $geom
393      * @return string
394      */
395     private function linestringToGPX($geom)
396     {
397         $isTrack = $geom->getData('gpxType') === 'route' ? false : true;
398         if ($geom->isEmpty() || !in_array($isTrack ? 'trk' : 'rte', $this->gpxTypes->get('gpxType'))) {
399             return '';
400         }
401 
402         if ($isTrack) { // write as <trk>
403 
404             /** @noinspection SpellCheckingInspection */
405             $gpx = "<" . $this->nss . "trk>\n" . self::processGeometryData($geom, $this->gpxTypes->get('trkType'));
406             $components = $geom->geometryType() === 'LineString' ? [$geom] : $geom->getComponents();
407             foreach ($components as $lineString) {
408                 $gpx .= "\t<" . $this->nss . "trkseg>\n";
409                 foreach ($lineString->getPoints() as $point) {
410                     $gpx .= $this->pointToGPX($point, 'trkpt');
411                 }
412                 $gpx .= "\t</" . $this->nss . "trkseg>\n";
413             }
414             /** @noinspection SpellCheckingInspection */
415             $gpx .= "</" . $this->nss . "trk>\n";
416         } else {    // write as <rte>
417 
418             /** @noinspection SpellCheckingInspection */
419             $gpx = "<" . $this->nss . "rte>\n" . self::processGeometryData($geom, $this->gpxTypes->get('rteType'));
420             foreach ($geom->getPoints() as $point) {
421                 $gpx .= $this->pointToGPX($point, 'rtept');
422             }
423             /** @noinspection SpellCheckingInspection */
424             $gpx .= "</" . $this->nss . "rte>\n";
425         }
426 
427         return $gpx;
428     }
429 
430     /**
431      * @param Collection $geometry
432      * @return string
433      */
434     public function collectionToGPX($geometry)
435     {
436         $metadata = self::processGeometryData($geometry, $this->gpxTypes->get('metadataType'));
437         $metadata = empty($metadata) || !in_array('metadataType', $this->gpxTypes->get('gpxType'))
438                 ? ''
439                 : "<metadata>\n{$metadata}</metadata>\n\n";
440         $wayPoints = $routes = $tracks = "";
441 
442         foreach ($geometry->getComponents() as $component) {
443             if (strpos($component->geometryType(), 'Point') !== false) {
444                 $wayPoints .= $this->geometryToGPX($component);
445             }
446             if (strpos($component->geometryType(), 'LineString') !== false && $component->getData('gpxType') === 'route') {
447                 $routes .= $this->geometryToGPX($component);
448             }
449             if (strpos($component->geometryType(), 'LineString') !== false && $component->getData('gpxType') !== 'route') {
450                 $tracks .= $this->geometryToGPX($component);
451             }
452             if (strpos($component->geometryType(), 'Point') === false && strpos($component->geometryType(), 'LineString') === false) {
453                 return $this->geometryToGPX($component);
454             }
455         }
456 
457         return $metadata . $wayPoints . $routes . $tracks;
458     }
459 
460     /**
461      * @param Geometry $geometry
462      * @param string[] $tagList Allowed tags
463      * @param string $indent
464      * @return string
465      */
466     protected static function processGeometryData($geometry, $tagList, $indent = "\t")
467     {
468         $tags = '';
469         if ($geometry->getData() !== null) {
470             foreach ($tagList as $tagName) {
471                 if ($geometry->hasDataProperty($tagName)) {
472                     $tags .= self::createNodes($tagName, $geometry->getData($tagName), $indent) . "\n";
473                 }
474             }
475         }
476         return $tags;
477     }
478 
479     /**
480      * @param string $tagName
481      * @param string|array $value
482      * @param string $indent
483      * @return string
484      */
485     protected static function createNodes($tagName, $value, $indent)
486     {
487         $attributes = '';
488         if (!is_array($value)) {
489             $returnValue = $value;
490         } else {
491             $returnValue = '';
492             if (array_key_exists('@attributes', $value)) {
493                 $attributes = '';
494                 foreach ($value['@attributes'] as $attributeName => $attributeValue) {
495                     $attributes .= ' ' . $attributeName . '="' . $attributeValue . '"';
496                 }
497                 unset($value['@attributes']);
498             }
499             foreach ($value as $subKey => $subValue) {
500                 $returnValue .= "\n" . self::createNodes($subKey, $subValue, $indent . "\t") . "\n" . $indent;
501             }
502         }
503         return $indent . "<{$tagName}{$attributes}>{$returnValue}</{$tagName}>";
504     }
505 }
506