1<?php
2
3namespace geoPHP\Adapter;
4
5use DOMDocument;
6use DOMElement;
7use DOMXPath;
8use geoPHP\Geometry\Collection;
9use geoPHP\geoPHP;
10use geoPHP\Geometry\Geometry;
11use geoPHP\Geometry\GeometryCollection;
12use geoPHP\Geometry\Point;
13use geoPHP\Geometry\LineString;
14use 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 */
27class 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