Passed
Push — master ( 4317be...c734ca )
by Swen
13:25
created

GPX::collectionToGPX()   B

Complexity

Conditions 7
Paths 20

Size

Total Lines 24
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Importance

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